invoicing.first_number's refusal (LedgerError::FirstNumberLocked) lands as the contract the store's check raises once invoices carry numbers (S4.3): a series that has begun is never renumbered (B-40, A-2). Also: the settings invariants name the number prefix's bound, update_party's doc says which facts wait for documents_v2, and the email check no longer reads as a single-arm match. Terms is S4.1's as it landed; this slice no longer carries a copy.
8269 lines
329 KiB
Rust
8269 lines
329 KiB
Rust
//! The ledger: the command surface of the engine.
|
||
//!
|
||
//! A [`Ledger`] owns the chart of accounts and the append-only log of posted
|
||
//! journal entries. Every mutation is a method on `Ledger` — create, add
|
||
//! line, post, reverse — and every method validates before it applies. There
|
||
//! is no other way to change the books, which is what makes the ledger an
|
||
//! audit trail and what will make the future oracle harness trustworthy.
|
||
|
||
use crate::schedules::{
|
||
self, AmortizationSchedule, NewRecurring, NewSchedule, RecurringEntry, RecurringId, ScheduleId,
|
||
};
|
||
use crate::{
|
||
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
|
||
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
|
||
EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money,
|
||
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets, kernel,
|
||
payroll::{
|
||
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
|
||
payroll_remit_plan, validate_payroll_lines,
|
||
},
|
||
periods,
|
||
};
|
||
use chrono::{Months, NaiveDate};
|
||
use rust_decimal::Decimal;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
|
||
/// An account the engine posts to by code: invoices, bills, payroll, and
|
||
/// closes find (or create) these; a command refuses one of the wrong type
|
||
/// (`LedgerError::ControlAccountMismatch`).
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct ControlAccount {
|
||
/// The role's stable name, for assigning it an account
|
||
/// ([`Ledger::assign_role`]): `receivables`, `payables`, ...
|
||
pub key: &'static str,
|
||
/// The code the engine looks for when the role has no account assigned.
|
||
pub code: &'static str,
|
||
/// The name it gives the account when it creates it.
|
||
pub name: &'static str,
|
||
/// What the account is for, in a refusal message.
|
||
pub role: &'static str,
|
||
/// The type the role needs.
|
||
pub account_type: AccountType,
|
||
}
|
||
|
||
const fn control(
|
||
key: &'static str,
|
||
code: &'static str,
|
||
name: &'static str,
|
||
role: &'static str,
|
||
account_type: AccountType,
|
||
) -> ControlAccount {
|
||
ControlAccount {
|
||
key,
|
||
code,
|
||
name,
|
||
role,
|
||
account_type,
|
||
}
|
||
}
|
||
|
||
/// A control account; see [`ControlAccount`].
|
||
pub const RECEIVABLES: ControlAccount = control(
|
||
"receivables",
|
||
"1200",
|
||
"Accounts Receivable",
|
||
"accounts receivable",
|
||
AccountType::Asset,
|
||
);
|
||
/// A control account; see [`ControlAccount`].
|
||
pub const REVENUE: ControlAccount = control(
|
||
"revenue",
|
||
"4000",
|
||
"Revenue",
|
||
"invoice revenue",
|
||
AccountType::Revenue,
|
||
);
|
||
/// A control account; see [`ControlAccount`].
|
||
pub const PAYABLES: ControlAccount = control(
|
||
"payables",
|
||
"2000",
|
||
"Accounts Payable",
|
||
"accounts payable",
|
||
AccountType::Liability,
|
||
);
|
||
/// A control account; see [`ControlAccount`].
|
||
pub const GENERAL_EXPENSES: ControlAccount = control(
|
||
"general_expense",
|
||
"6999",
|
||
"General Expenses",
|
||
"bill expense",
|
||
AccountType::Expense,
|
||
);
|
||
/// A control account; see [`ControlAccount`].
|
||
pub const RETAINED_EARNINGS: ControlAccount = control(
|
||
"retained_earnings",
|
||
periods::RETAINED_EARNINGS_CODE,
|
||
"Retained Earnings",
|
||
"retained earnings",
|
||
AccountType::Equity,
|
||
);
|
||
/// Every control account the engine posts to by code.
|
||
pub fn control_accounts() -> impl Iterator<Item = &'static ControlAccount> {
|
||
[
|
||
&RECEIVABLES,
|
||
&REVENUE,
|
||
&PAYABLES,
|
||
&GENERAL_EXPENSES,
|
||
&RETAINED_EARNINGS,
|
||
]
|
||
.into_iter()
|
||
.chain(PAYROLL.iter())
|
||
}
|
||
|
||
/// The control role named `key` (see [`ControlAccount::key`]).
|
||
pub fn control_account(key: &str) -> Option<&'static ControlAccount> {
|
||
control_accounts().find(|c| c.key == key)
|
||
}
|
||
|
||
/// The payroll accounts, in [`PayrollAccounts`] order.
|
||
pub const PAYROLL: [ControlAccount; 7] = [
|
||
control(
|
||
"salary_expense",
|
||
"6100",
|
||
"Salary & Wage Expense",
|
||
"salary expense",
|
||
AccountType::Expense,
|
||
),
|
||
control(
|
||
"payroll_tax_expense",
|
||
"6110",
|
||
"Payroll Tax Expense",
|
||
"payroll tax expense",
|
||
AccountType::Expense,
|
||
),
|
||
control(
|
||
"federal_withholding",
|
||
"2110",
|
||
"Federal Withholding Payable",
|
||
"federal withholding payable",
|
||
AccountType::Liability,
|
||
),
|
||
control(
|
||
"state_withholding",
|
||
"2120",
|
||
"State Withholding Payable",
|
||
"state withholding payable",
|
||
AccountType::Liability,
|
||
),
|
||
control(
|
||
"social_security_payable",
|
||
"2130",
|
||
"SS Tax Payable",
|
||
"social security tax payable",
|
||
AccountType::Liability,
|
||
),
|
||
control(
|
||
"medicare_payable",
|
||
"2140",
|
||
"Medicare Tax Payable",
|
||
"Medicare tax payable",
|
||
AccountType::Liability,
|
||
),
|
||
control(
|
||
"other_deductions_payable",
|
||
"2150",
|
||
"Other Deductions Payable",
|
||
"other deductions payable",
|
||
AccountType::Liability,
|
||
),
|
||
];
|
||
|
||
/// A posted line that has not cleared, for the reconciliation report.
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct UnclearedLine {
|
||
/// Entry id.
|
||
pub entry_id: EntryId,
|
||
/// Index within the entry.
|
||
pub line_index: usize,
|
||
/// Entry date.
|
||
pub date: NaiveDate,
|
||
/// Entry description.
|
||
pub description: String,
|
||
/// Line side.
|
||
pub side: Side,
|
||
/// Line amount (base currency).
|
||
pub amount: Decimal,
|
||
}
|
||
|
||
/// The bank reconciliation report.
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct ReconciliationReport {
|
||
/// Account id reconciled.
|
||
pub account: AccountId,
|
||
/// Sum of all posted lines on the account (book balance).
|
||
pub book_balance: Decimal,
|
||
/// Sum of cleared lines dated on or before the statement date.
|
||
pub cleared_balance: Decimal,
|
||
/// The bank statement's balance.
|
||
pub statement_balance: Decimal,
|
||
/// cleared_balance - statement_balance. Zero means reconciled.
|
||
pub difference: Decimal,
|
||
/// Posted, uncleared lines dated on or before the statement date.
|
||
pub uncleared_lines: Vec<UnclearedLine>,
|
||
}
|
||
|
||
/// What a posting template drafts: its description, and the lines
|
||
/// `(account, side, amount)` ([`Ledger::template_lines`]).
|
||
pub type TemplateDraft = (Option<String>, Vec<(AccountId, Side, Money)>);
|
||
|
||
/// The books. Single-currency (see R-5); owns id allocation.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Ledger {
|
||
base_currency: Currency,
|
||
chart: ChartOfAccounts,
|
||
entries: HashMap<EntryId, JournalEntry>,
|
||
/// Entry ids in posting order: the append-only log.
|
||
order: Vec<EntryId>,
|
||
/// Fixed-asset register: master data, journaled like accounts.
|
||
assets: HashMap<AssetId, Asset>,
|
||
/// Accounting periods.
|
||
periods: HashMap<PeriodId, Period>,
|
||
/// Customers for A/R.
|
||
customers: HashMap<CustomerId, Customer>,
|
||
/// Vendors for A/P.
|
||
vendors: HashMap<VendorId, Vendor>,
|
||
/// Sales invoices.
|
||
invoices: HashMap<InvoiceId, Invoice>,
|
||
/// Vendor bills.
|
||
bills: HashMap<BillId, Bill>,
|
||
/// Cleared flags for bank reconciliation: (entry_id, line_index) -> cleared.
|
||
/// Posted entries are immutable; this side table holds the workflow flag.
|
||
cleared: HashMap<(EntryId, usize), bool>,
|
||
/// Historical FX rates for reference: (currency, date) -> rate (foreign -> base).
|
||
fx_rates: HashMap<(Currency, NaiveDate), Decimal>,
|
||
/// Payroll runs.
|
||
payroll_runs: HashMap<PayrollRunId, PayrollRun>,
|
||
/// Classes for divisional P&L tracking.
|
||
classes: HashMap<ClassId, Class>,
|
||
/// The project facts set for classes (rules.md B-37); a class not here
|
||
/// has the defaults.
|
||
projects: std::collections::BTreeMap<ClassId, crate::classes::ProjectFacts>,
|
||
next_account_id: AccountId,
|
||
next_entry_id: EntryId,
|
||
next_asset_id: AssetId,
|
||
next_period_id: PeriodId,
|
||
next_customer_id: CustomerId,
|
||
next_vendor_id: VendorId,
|
||
next_invoice_id: InvoiceId,
|
||
next_bill_id: BillId,
|
||
next_payroll_run_id: PayrollRunId,
|
||
next_class_id: ClassId,
|
||
/// Amortization schedules (prepaid expenses, deferred revenue).
|
||
schedules: std::collections::BTreeMap<ScheduleId, AmortizationSchedule>,
|
||
next_schedule_id: ScheduleId,
|
||
/// Recurring entries.
|
||
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
|
||
next_recurring_id: RecurringId,
|
||
/// Who each entry paid or was billed by, for 1099 reporting.
|
||
entry_vendors: HashMap<EntryId, VendorId>,
|
||
/// The payment methods ([`crate::instruments`]), by id.
|
||
instruments: std::collections::BTreeMap<crate::InstrumentId, crate::Instrument>,
|
||
next_instrument_id: crate::InstrumentId,
|
||
/// The payment method each entry was paid with, for 1099 reporting.
|
||
entry_instruments: HashMap<EntryId, crate::InstrumentId>,
|
||
/// The document each receivables or payables line belongs to, by
|
||
/// (entry, line index) (rules.md B-38, plan P4b.3): written by the
|
||
/// document commands in the step that posts the line, derived on
|
||
/// replay from the document events, and copied onto a reversal's
|
||
/// mirror line. Every figure about a document is read from these.
|
||
line_docs: std::collections::BTreeMap<(EntryId, usize), arap::DocTag>,
|
||
/// Every payment event recorded against a document, each amount once,
|
||
/// whether its entry stands and whether its line could be tagged:
|
||
/// what a version before document standing counts as paid
|
||
/// (`payments_recorded`; plan P4b.7, `document_standing`).
|
||
payment_events: std::collections::BTreeMap<arap::DocRef, Vec<Decimal>>,
|
||
/// Indexes derived from the fields above, kept as they change so the
|
||
/// figures read from them stay linear (none is state of its own):
|
||
/// each document's tagged lines (`line_docs` by document)...
|
||
doc_index:
|
||
std::collections::BTreeMap<arap::DocRef, std::collections::BTreeSet<(EntryId, usize)>>,
|
||
/// ... the accounts holding a document's line, and whether an
|
||
/// invoice's (receivables) or a bill's (payables)...
|
||
doc_accounts: std::collections::BTreeSet<(AccountId, bool)>,
|
||
/// ... each posted entry's place in `order`...
|
||
order_pos: HashMap<EntryId, usize>,
|
||
/// ... and the posted entry reversing each reversed entry (the first
|
||
/// posted, as `reversal_chain` reads it).
|
||
reversed_by: HashMap<EntryId, EntryId>,
|
||
/// Control roles assigned an account ([`Ledger::assign_role`]), by
|
||
/// [`ControlAccount::key`]. A role not here uses its code.
|
||
roles: std::collections::BTreeMap<&'static str, AccountId>,
|
||
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
|
||
/// held it): a tag never moves to another asset.
|
||
asset_tags: std::collections::BTreeMap<String, AssetId>,
|
||
/// Each asset's depreciation runs (`asset.depreciation_posted`), in
|
||
/// the order recorded: what the books carry for it
|
||
/// ([`Ledger::accumulated_posted`]).
|
||
asset_runs: std::collections::BTreeMap<AssetId, Vec<assets::DepreciationPosting>>,
|
||
/// The party directory ([`crate::parties`]).
|
||
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
||
next_party_id: crate::PartyId,
|
||
/// The parties each entry is with (its counterparties), each with
|
||
/// their share ([`crate::shares`]); never an empty list.
|
||
entry_parties: HashMap<EntryId, Vec<crate::PartyShare>>,
|
||
/// Entries marked as having no counterparty, with why.
|
||
entry_no_party: HashMap<EntryId, String>,
|
||
/// The settings the journal holds ([`crate::settings`]).
|
||
settings: crate::settings::Settings,
|
||
/// Conventions, decisions, open items and approvals
|
||
/// ([`crate::conventions`]).
|
||
conventions: crate::conventions::Conventions,
|
||
}
|
||
|
||
impl Ledger {
|
||
/// Open a new, empty ledger denominated in `base_currency`.
|
||
pub fn new(base_currency: Currency) -> Self {
|
||
Self {
|
||
base_currency,
|
||
chart: ChartOfAccounts::default(),
|
||
entries: HashMap::new(),
|
||
order: Vec::new(),
|
||
assets: HashMap::new(),
|
||
periods: HashMap::new(),
|
||
customers: HashMap::new(),
|
||
vendors: HashMap::new(),
|
||
invoices: HashMap::new(),
|
||
bills: HashMap::new(),
|
||
cleared: HashMap::new(),
|
||
fx_rates: HashMap::new(),
|
||
payroll_runs: HashMap::new(),
|
||
classes: HashMap::new(),
|
||
projects: std::collections::BTreeMap::new(),
|
||
next_account_id: 1,
|
||
next_entry_id: 1,
|
||
next_asset_id: 1,
|
||
next_period_id: 1,
|
||
next_customer_id: 1,
|
||
next_vendor_id: 1,
|
||
next_invoice_id: 1,
|
||
next_bill_id: 1,
|
||
next_payroll_run_id: 1,
|
||
next_class_id: 1,
|
||
schedules: std::collections::BTreeMap::new(),
|
||
next_schedule_id: 1,
|
||
recurring: std::collections::BTreeMap::new(),
|
||
next_recurring_id: 1,
|
||
entry_vendors: HashMap::new(),
|
||
instruments: std::collections::BTreeMap::new(),
|
||
next_instrument_id: 1,
|
||
entry_instruments: HashMap::new(),
|
||
line_docs: std::collections::BTreeMap::new(),
|
||
payment_events: std::collections::BTreeMap::new(),
|
||
doc_index: std::collections::BTreeMap::new(),
|
||
doc_accounts: std::collections::BTreeSet::new(),
|
||
order_pos: HashMap::new(),
|
||
reversed_by: HashMap::new(),
|
||
roles: std::collections::BTreeMap::new(),
|
||
asset_tags: std::collections::BTreeMap::new(),
|
||
asset_runs: std::collections::BTreeMap::new(),
|
||
parties: std::collections::BTreeMap::new(),
|
||
next_party_id: 1,
|
||
entry_parties: HashMap::new(),
|
||
entry_no_party: HashMap::new(),
|
||
settings: crate::settings::Settings::default(),
|
||
conventions: crate::conventions::Conventions::default(),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Amortization schedules and recurring entries
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Register an amortization schedule. Returns its id.
|
||
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
||
schedules::validate_schedule(&new, &self.chart)?;
|
||
self.replay_register_schedule(new)
|
||
}
|
||
|
||
/// [`Self::register_schedule`] without the total's range and minor-unit
|
||
/// checks added on 2026-10-13, or the calendar bound and the
|
||
/// equal-months start on the 1st (D-37) added since, for replaying
|
||
/// journals written before them. Never for new work.
|
||
pub fn replay_register_schedule(
|
||
&mut self,
|
||
new: NewSchedule,
|
||
) -> Result<ScheduleId, LedgerError> {
|
||
schedules::validate_schedule_shape(&new, &self.chart)?;
|
||
let id = self.next_schedule_id;
|
||
self.next_schedule_id += 1;
|
||
self.schedules.insert(
|
||
id,
|
||
AmortizationSchedule {
|
||
id,
|
||
description: new.description,
|
||
kind: new.kind,
|
||
total: new.total,
|
||
start: new.start,
|
||
months: new.months,
|
||
balance_account: new.balance_account,
|
||
pl_account: new.pl_account,
|
||
posted: Default::default(),
|
||
convention: new.convention,
|
||
postings: Default::default(),
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Whether a schedule's month was ever posted (and so, if it isn't
|
||
/// posted now, a reversal un-posted it): posting it again needs a
|
||
/// journal an older version refuses (store feature `month_reposting`).
|
||
pub fn schedule_month_posted_before(&self, id: ScheduleId, month_end: NaiveDate) -> bool {
|
||
self.schedules
|
||
.get(&id)
|
||
.and_then(|s| s.postings.get(&month_end))
|
||
.is_some_and(|p| !p.is_empty())
|
||
}
|
||
|
||
/// The latest date after a schedule month's end that one of its
|
||
/// postings was reversed (or re-instated) on, if any: posting the month
|
||
/// again on its own date would have the books carry it twice from then
|
||
/// until that date, so it is left for a human
|
||
/// ([`Self::preview_schedule_month`], [`Self::schedule_month_hold`]).
|
||
///
|
||
/// ```text
|
||
/// GOAL: a schedule month is never recognized twice at a month end.
|
||
/// SOURCE: docs/spec/rules.md B-9 (a correction is dated when it is
|
||
/// made), B-16; P3 depreciation review R3 (a reversed month is
|
||
/// due again: when the reversal is the month's own).
|
||
/// ```
|
||
pub fn schedule_month_reversed_after(
|
||
&self,
|
||
id: ScheduleId,
|
||
month_end: NaiveDate,
|
||
) -> Option<NaiveDate> {
|
||
self.schedules
|
||
.get(&id)?
|
||
.postings
|
||
.get(&month_end)?
|
||
.iter()
|
||
.flat_map(|&e| {
|
||
self.reversal_chain(e)
|
||
.into_iter()
|
||
.skip_while(move |&c| c != e)
|
||
.skip(1)
|
||
})
|
||
.filter_map(|c| self.entries.get(&c).map(JournalEntry::date))
|
||
.filter(|d| *d > month_end)
|
||
.max()
|
||
}
|
||
|
||
/// Why a run of schedules leaves a due month for a human, if it must:
|
||
/// it is dated in a closed period (which takes no new entries), or its
|
||
/// posting was reversed after the month ended
|
||
/// ([`Self::schedule_month_reversed_after`]).
|
||
pub fn schedule_month_hold(&self, id: ScheduleId, month_end: NaiveDate) -> Option<String> {
|
||
if let Some(p) = self.closed_period_containing(month_end) {
|
||
return Some(format!(
|
||
"{month_end} is in closed period {p}: book it in an open one by hand, or not at all"
|
||
));
|
||
}
|
||
self.schedule_month_reversed_after(id, month_end).map(|r| {
|
||
format!(
|
||
"its posting was reversed on {r}, after the month ended: posting it again on {month_end} would recognize the month twice until then; book it by hand in an open period, or not at all"
|
||
)
|
||
})
|
||
}
|
||
|
||
/// The id the next schedule will receive (for journaling stores).
|
||
pub fn next_schedule_id(&self) -> ScheduleId {
|
||
self.next_schedule_id
|
||
}
|
||
|
||
/// All schedules, by id.
|
||
pub fn schedules(&self) -> impl Iterator<Item = &AmortizationSchedule> + '_ {
|
||
self.schedules.values()
|
||
}
|
||
|
||
/// Look up a schedule.
|
||
pub fn get_schedule(&self, id: ScheduleId) -> Option<&AmortizationSchedule> {
|
||
self.schedules.get(&id)
|
||
}
|
||
|
||
/// The entry [`Self::post_schedule_month`] would post: (date,
|
||
/// description, lines). Errors if the month isn't due and unposted.
|
||
pub fn preview_schedule_month(
|
||
&self,
|
||
id: ScheduleId,
|
||
month_end: NaiveDate,
|
||
) -> Result<(NaiveDate, String, Vec<crate::schedules::TemplateLine>), LedgerError> {
|
||
let s = self
|
||
.schedules
|
||
.get(&id)
|
||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown schedule {id}")))?;
|
||
let (date, amount) = s
|
||
.plan()?
|
||
.into_iter()
|
||
.find(|(d, _)| *d == month_end)
|
||
.ok_or_else(|| {
|
||
LedgerError::InvalidSchedule(format!("{month_end} is not a month of schedule {id}"))
|
||
})?;
|
||
if s.posted.contains(&date) {
|
||
return Err(LedgerError::InvalidSchedule(format!(
|
||
"schedule {id} already posted {date}"
|
||
)));
|
||
}
|
||
if let Some(r) = self.schedule_month_reversed_after(id, date) {
|
||
return Err(LedgerError::InvalidSchedule(format!(
|
||
"schedule {id}'s {date} posting was reversed on {r}, after its month ended: posting it again on {date} would recognize the month twice until then (book it by hand in an open period, or not at all)"
|
||
)));
|
||
}
|
||
let money = Money::new(amount, s.total.currency);
|
||
let (debit, credit) = s.accounts();
|
||
let what = match s.kind {
|
||
schedules::ScheduleKind::PrepaidExpense => "Prepaid expense",
|
||
schedules::ScheduleKind::DeferredRevenue => "Deferred revenue",
|
||
};
|
||
Ok((
|
||
date,
|
||
format!("{what}: {} ({})", s.description, date.format("%Y-%m")),
|
||
vec![(debit, Side::Debit, money), (credit, Side::Credit, money)],
|
||
))
|
||
}
|
||
|
||
/// Post one month of a schedule: the entry from
|
||
/// [`Self::preview_schedule_month`], posted and recorded so the month
|
||
/// never posts twice. Returns the entry id.
|
||
pub fn post_schedule_month(
|
||
&mut self,
|
||
id: ScheduleId,
|
||
month_end: NaiveDate,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.atomically(|l| l.post_schedule_month_steps(id, month_end))
|
||
}
|
||
|
||
/// The steps of [`Self::post_schedule_month`], run inside [`Self::atomically`].
|
||
fn post_schedule_month_steps(
|
||
&mut self,
|
||
id: ScheduleId,
|
||
month_end: NaiveDate,
|
||
) -> Result<EntryId, LedgerError> {
|
||
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
||
if let Some(p) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(p, date.to_string()));
|
||
}
|
||
let entry = self.create_entry(date, &description);
|
||
for (account, side, money) in lines {
|
||
self.add_line(entry, account, side, money)?;
|
||
}
|
||
self.post_entry(entry)?;
|
||
let s = self.schedules.get_mut(&id).expect("previewed");
|
||
s.posted.insert(date);
|
||
s.postings.entry(date).or_default().push(entry);
|
||
Ok(entry)
|
||
}
|
||
|
||
/// Register a recurring entry. Returns its id. A template naming a
|
||
/// receivables or payables account is refused (UntaggedControlLine,
|
||
/// the control guard: plan P4b.3), since every draft it made would be.
|
||
pub fn register_recurring(&mut self, new: NewRecurring) -> Result<RecurringId, LedgerError> {
|
||
schedules::validate_recurring(&new, &self.chart)?;
|
||
if let Some((i, (account, ..))) = new
|
||
.lines
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, (a, ..))| self.control_role_of(*a).is_some())
|
||
{
|
||
return Err(LedgerError::UntaggedControlLine {
|
||
account: self.account_label(*account),
|
||
at: format!("line {i} of the recurring template"),
|
||
});
|
||
}
|
||
self.insert_recurring(new)
|
||
}
|
||
|
||
/// [`Self::register_recurring`] with the checks every journaled
|
||
/// `recurring.registered` passed when it was written
|
||
/// (schedules::validate_recurring_journaled), for replay only
|
||
/// (rules.md B-28). Never for new work.
|
||
pub fn replay_register_recurring(
|
||
&mut self,
|
||
new: NewRecurring,
|
||
) -> Result<RecurringId, LedgerError> {
|
||
schedules::validate_recurring_journaled(&new, &self.chart)?;
|
||
self.insert_recurring(new)
|
||
}
|
||
|
||
/// Put a checked recurring entry in under the next id.
|
||
fn insert_recurring(&mut self, new: NewRecurring) -> Result<RecurringId, LedgerError> {
|
||
let id = self.next_recurring_id;
|
||
self.next_recurring_id += 1;
|
||
self.recurring.insert(
|
||
id,
|
||
RecurringEntry {
|
||
id,
|
||
description: new.description,
|
||
lines: new.lines,
|
||
frequency: new.frequency,
|
||
start: new.start,
|
||
end: new.end,
|
||
generated: Default::default(),
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// The id the next recurring entry will receive (for journaling stores).
|
||
pub fn next_recurring_id(&self) -> RecurringId {
|
||
self.next_recurring_id
|
||
}
|
||
|
||
/// All recurring entries, by id.
|
||
pub fn recurring_entries(&self) -> impl Iterator<Item = &RecurringEntry> + '_ {
|
||
self.recurring.values()
|
||
}
|
||
|
||
/// Generate one occurrence of a recurring entry as a *draft* (never
|
||
/// posted: the agent attaches the receipt and posts it). `date` must be
|
||
/// an occurrence not yet generated. Returns the draft's id.
|
||
///
|
||
/// ```text
|
||
/// GOAL: turn one due occurrence of a recurring entry into a draft.
|
||
/// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen.
|
||
/// SCENARIOS:
|
||
/// 1. IF the date falls in a closed period:
|
||
/// - SHOULD refuse (ClosedPeriod)
|
||
/// - NEVER create a draft there
|
||
/// BECAUSE a closed period takes no new entries, however they
|
||
/// are made (found by the store's refusal property, 2026-10-13).
|
||
/// 2. IF the date is not an ungenerated occurrence: SHOULD refuse.
|
||
/// 3. IF a line is refused: SHOULD leave the ledger as it was (no
|
||
/// half-built draft, no id spent).
|
||
/// 4. Otherwise: SHOULD create a draft with the template's lines and
|
||
/// mark the occurrence generated.
|
||
/// ```
|
||
pub fn generate_recurring(
|
||
&mut self,
|
||
id: RecurringId,
|
||
date: NaiveDate,
|
||
) -> Result<EntryId, LedgerError> {
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
self.replay_generate_recurring(id, date)
|
||
}
|
||
|
||
/// [`Self::generate_recurring`] without the closed-period check, for
|
||
/// replaying journals written before that check existed.
|
||
pub fn replay_generate_recurring(
|
||
&mut self,
|
||
id: RecurringId,
|
||
date: NaiveDate,
|
||
) -> Result<EntryId, LedgerError> {
|
||
let r = self
|
||
.recurring
|
||
.get(&id)
|
||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown recurring entry {id}")))?;
|
||
if !r.due(date).contains(&date) {
|
||
return Err(LedgerError::InvalidSchedule(format!(
|
||
"{date} is not an ungenerated occurrence of recurring entry {id}"
|
||
)));
|
||
}
|
||
let (description, lines) = (r.description.clone(), r.lines.clone());
|
||
let entry = self.create_entry(date, &description);
|
||
for (account, side, money) in lines {
|
||
// The template's lines as journaled: no size bound (rules.md
|
||
// B-28); a draft too large to post is refused at its post.
|
||
if let Err(e) = self.add_line_journaled(entry, account, side, money, false) {
|
||
self.entries.remove(&entry);
|
||
self.next_entry_id = entry;
|
||
return Err(e);
|
||
}
|
||
}
|
||
self.recurring
|
||
.get_mut(&id)
|
||
.expect("checked")
|
||
.generated
|
||
.insert(date);
|
||
Ok(entry)
|
||
}
|
||
|
||
/// Occurrences due through `through` that can't be generated because
|
||
/// their date falls in a closed period, as (recurring entry, date).
|
||
/// They need a human decision (book them in an open period by hand,
|
||
/// or not at all).
|
||
pub fn recurring_in_closed_periods(&self, through: NaiveDate) -> Vec<(RecurringId, NaiveDate)> {
|
||
self.recurring
|
||
.values()
|
||
.flat_map(|r| r.due(through).into_iter().map(move |d| (r.id, d)))
|
||
.filter(|(_, d)| self.closed_period_containing(*d).is_some())
|
||
.collect()
|
||
}
|
||
|
||
/// The parts of the ledger that differ from `other`, by field name
|
||
/// (empty when equal). For diagnosing a store whose journal replay and
|
||
/// projection disagree.
|
||
pub fn differs_from(&self, other: &Ledger) -> Vec<&'static str> {
|
||
[
|
||
("base_currency", self.base_currency == other.base_currency),
|
||
("chart", self.chart == other.chart),
|
||
("entries", self.entries == other.entries),
|
||
("order", self.order == other.order),
|
||
("assets", self.assets == other.assets),
|
||
("periods", self.periods == other.periods),
|
||
("customers", self.customers == other.customers),
|
||
("vendors", self.vendors == other.vendors),
|
||
("invoices", self.invoices == other.invoices),
|
||
("bills", self.bills == other.bills),
|
||
("cleared", self.cleared == other.cleared),
|
||
("fx_rates", self.fx_rates == other.fx_rates),
|
||
("payroll_runs", self.payroll_runs == other.payroll_runs),
|
||
("classes", self.classes == other.classes),
|
||
("projects", self.projects == other.projects),
|
||
(
|
||
"next_account_id",
|
||
self.next_account_id == other.next_account_id,
|
||
),
|
||
("next_entry_id", self.next_entry_id == other.next_entry_id),
|
||
("next_asset_id", self.next_asset_id == other.next_asset_id),
|
||
(
|
||
"next_period_id",
|
||
self.next_period_id == other.next_period_id,
|
||
),
|
||
(
|
||
"next_customer_id",
|
||
self.next_customer_id == other.next_customer_id,
|
||
),
|
||
(
|
||
"next_vendor_id",
|
||
self.next_vendor_id == other.next_vendor_id,
|
||
),
|
||
(
|
||
"next_invoice_id",
|
||
self.next_invoice_id == other.next_invoice_id,
|
||
),
|
||
("next_bill_id", self.next_bill_id == other.next_bill_id),
|
||
(
|
||
"next_payroll_run_id",
|
||
self.next_payroll_run_id == other.next_payroll_run_id,
|
||
),
|
||
("next_class_id", self.next_class_id == other.next_class_id),
|
||
("schedules", self.schedules == other.schedules),
|
||
(
|
||
"next_schedule_id",
|
||
self.next_schedule_id == other.next_schedule_id,
|
||
),
|
||
("recurring", self.recurring == other.recurring),
|
||
(
|
||
"next_recurring_id",
|
||
self.next_recurring_id == other.next_recurring_id,
|
||
),
|
||
("entry_vendors", self.entry_vendors == other.entry_vendors),
|
||
("instruments", self.instruments == other.instruments),
|
||
(
|
||
"next_instrument_id",
|
||
self.next_instrument_id == other.next_instrument_id,
|
||
),
|
||
(
|
||
"entry_instruments",
|
||
self.entry_instruments == other.entry_instruments,
|
||
),
|
||
("line_docs", self.line_docs == other.line_docs),
|
||
(
|
||
"payment_events",
|
||
self.payment_events == other.payment_events,
|
||
),
|
||
("roles", self.roles == other.roles),
|
||
("asset_tags", self.asset_tags == other.asset_tags),
|
||
("asset_runs", self.asset_runs == other.asset_runs),
|
||
("parties", self.parties == other.parties),
|
||
("next_party_id", self.next_party_id == other.next_party_id),
|
||
("entry_parties", self.entry_parties == other.entry_parties),
|
||
(
|
||
"entry_no_party",
|
||
self.entry_no_party == other.entry_no_party,
|
||
),
|
||
("settings", self.settings == other.settings),
|
||
("conventions", self.conventions == other.conventions),
|
||
]
|
||
.into_iter()
|
||
.filter(|(_, same)| !same)
|
||
.map(|(name, _)| name)
|
||
.collect()
|
||
}
|
||
|
||
/// Run a command made of several steps as one: if any step is
|
||
/// refused, the ledger is exactly as it was (no half-built draft, no
|
||
/// id spent). Found 2026-10-13: a schedule month whose amount was out
|
||
/// of range left a draft behind.
|
||
fn atomically<T>(
|
||
&mut self,
|
||
steps: impl FnOnce(&mut Self) -> Result<T, LedgerError>,
|
||
) -> Result<T, LedgerError> {
|
||
let before = self.clone();
|
||
steps(self).inspect_err(|_| *self = before)
|
||
}
|
||
|
||
/// The id the next account added will receive.
|
||
///
|
||
/// Exposed for journaling stores that record the id before mutating.
|
||
/// The value is only stable if no other mutation intervenes.
|
||
pub fn next_account_id(&self) -> AccountId {
|
||
self.next_account_id
|
||
}
|
||
|
||
/// The id the next journal entry created will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_entry_id(&self) -> EntryId {
|
||
self.next_entry_id
|
||
}
|
||
|
||
/// The id the next fixed asset registered will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_asset_id(&self) -> AssetId {
|
||
self.next_asset_id
|
||
}
|
||
|
||
/// The id the next accounting period created will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_period_id(&self) -> PeriodId {
|
||
self.next_period_id
|
||
}
|
||
|
||
/// The id the next customer added will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_customer_id(&self) -> CustomerId {
|
||
self.next_customer_id
|
||
}
|
||
|
||
/// The id the next vendor added will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_vendor_id(&self) -> VendorId {
|
||
self.next_vendor_id
|
||
}
|
||
|
||
/// The id the next invoice created will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_invoice_id(&self) -> InvoiceId {
|
||
self.next_invoice_id
|
||
}
|
||
|
||
/// The id the next bill created will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_bill_id(&self) -> BillId {
|
||
self.next_bill_id
|
||
}
|
||
|
||
/// The id the next payroll run will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_payroll_run_id(&self) -> PayrollRunId {
|
||
self.next_payroll_run_id
|
||
}
|
||
|
||
/// The id the next class added will receive.
|
||
///
|
||
/// See [`Self::next_account_id`].
|
||
pub fn next_class_id(&self) -> ClassId {
|
||
self.next_class_id
|
||
}
|
||
|
||
/// The currency every account and line in this ledger uses.
|
||
pub fn base_currency(&self) -> Currency {
|
||
self.base_currency
|
||
}
|
||
|
||
/// Read-only view of the chart of accounts.
|
||
pub fn chart(&self) -> &ChartOfAccounts {
|
||
&self.chart
|
||
}
|
||
|
||
/// Add an account to the chart. Returns its id.
|
||
///
|
||
/// ```text
|
||
/// GOAL: register an account under a unique code and a name no
|
||
/// sibling has.
|
||
/// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner,
|
||
/// 2026-10-13): a child has its parent's type; rules.md B-31
|
||
/// (names: QuickBooks matches accounts by name,
|
||
/// docs/spec/sources/intuit-iif.md; no full TIN in what every
|
||
/// report prints, B-26; DECISION, owner, 2026-10-20).
|
||
/// SCENARIOS:
|
||
/// 1. IF the code is empty or taken: SHOULD refuse.
|
||
/// 2. IF the parent doesn't exist: SHOULD refuse.
|
||
/// 3. IF the parent is another type:
|
||
/// - SHOULD refuse with ParentTypeMismatch
|
||
/// - NEVER add the account
|
||
/// BECAUSE a child subdivides its parent.
|
||
/// 4. IF the name is blank: SHOULD refuse with EmptyAccountName.
|
||
/// 5. IF an account under the same parent (or, for a top-level
|
||
/// account, another top-level one) has the name, ignoring
|
||
/// capitals and surrounding space: SHOULD refuse with
|
||
/// DuplicateAccountName.
|
||
/// 6. IF the name holds a run of nine digits (a full SSN, EIN or
|
||
/// account number): SHOULD refuse with AccountNameHoldsTin.
|
||
/// 7. IF the name, trimmed, is longer than MAX_ACCOUNT_NAME
|
||
/// characters, or holds a line break, tab or other control
|
||
/// character: SHOULD refuse with InvalidAccountName
|
||
/// BECAUSE every report, export and the IIF print it on one line.
|
||
/// 8. The name is kept trimmed, as a rename keeps it.
|
||
/// ```
|
||
///
|
||
/// Replay of journals written before these rules uses
|
||
/// [`Self::replay_add_account`]; the accounts the engine creates for a
|
||
/// control role use [`Self::add_control_account`].
|
||
pub fn add_account(
|
||
&mut self,
|
||
code: &str,
|
||
name: &str,
|
||
account_type: AccountType,
|
||
parent: Option<AccountId>,
|
||
placeholder: bool,
|
||
) -> Result<AccountId, LedgerError> {
|
||
// A chart lists each account by number and title; neither may be
|
||
// blank (2026-10-13; the core refused only an empty code, so a code
|
||
// of spaces or an unnamed account got in).
|
||
if code.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAccountCode);
|
||
}
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return Err(LedgerError::EmptyAccountName);
|
||
}
|
||
if let Some(p) = parent.and_then(|p| self.chart.get(p))
|
||
&& p.account_type != account_type
|
||
{
|
||
return Err(LedgerError::ParentTypeMismatch {
|
||
code: code.to_string(),
|
||
parent_code: p.code.clone(),
|
||
expected: p.account_type,
|
||
found: account_type,
|
||
});
|
||
}
|
||
if self.chart.find_by_code(code).is_some() {
|
||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||
}
|
||
self.check_account_name(None, name, parent)?;
|
||
self.replay_add_account(code, name, account_type, parent, placeholder)
|
||
}
|
||
|
||
/// Create the account a control role posts to, at its code with its
|
||
/// name (invoices, bills, payroll and closes create them when first
|
||
/// needed). The engine's own names: never refused for a sibling's
|
||
/// name, and never for a missing tax category (rules.md B-32), so an
|
||
/// invoice is never refused over a chart label.
|
||
pub fn add_control_account(&mut self, c: &ControlAccount) -> Result<AccountId, LedgerError> {
|
||
self.replay_add_account(c.code, c.name, c.account_type, None, false)
|
||
}
|
||
|
||
/// The name rules for a new name (a new account's, or a rename's),
|
||
/// given trimmed and not blank: one line of at most
|
||
/// [`crate::account::MAX_ACCOUNT_NAME`] characters, no run of nine
|
||
/// digits, and none of `account`'s siblings under `parent` has it,
|
||
/// ignoring capitals and surrounding space ([`Self::add_account`]
|
||
/// SCENARIOS 5-7). `account` is the one being renamed, never its own
|
||
/// sibling.
|
||
fn check_account_name(
|
||
&self,
|
||
account: Option<AccountId>,
|
||
name: &str,
|
||
parent: Option<AccountId>,
|
||
) -> Result<(), LedgerError> {
|
||
let max = crate::account::MAX_ACCOUNT_NAME;
|
||
if name.chars().count() > max {
|
||
return Err(LedgerError::InvalidAccountName(format!(
|
||
"is longer than {max} characters: every report prints it on one line"
|
||
)));
|
||
}
|
||
if name.chars().any(char::is_control) {
|
||
return Err(LedgerError::InvalidAccountName(
|
||
"holds a line break, tab or other control character: every report prints it on one line".into(),
|
||
));
|
||
}
|
||
if crate::parties::has_full_tin(name) {
|
||
return Err(LedgerError::AccountNameHoldsTin);
|
||
}
|
||
let key = crate::parties::name_key(name);
|
||
let mut siblings: Vec<&Account> = self
|
||
.chart
|
||
.all()
|
||
.filter(|a| a.parent == parent && Some(a.id) != account)
|
||
.filter(|a| crate::parties::name_key(&a.name) == key)
|
||
.collect();
|
||
siblings.sort_by_key(|a| a.id);
|
||
match siblings.first() {
|
||
Some(a) => Err(LedgerError::DuplicateAccountName {
|
||
name: name.trim().to_string(),
|
||
sibling: format!("{} {}", a.code, a.name),
|
||
}),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
|
||
/// child's type; a blank code or name), for replaying journals written before them. Never
|
||
/// for new work.
|
||
pub fn replay_add_account(
|
||
&mut self,
|
||
code: &str,
|
||
name: &str,
|
||
account_type: AccountType,
|
||
parent: Option<AccountId>,
|
||
placeholder: bool,
|
||
) -> Result<AccountId, LedgerError> {
|
||
// The id is used only if the account is: a refusal uses up nothing.
|
||
let id = self.next_account_id;
|
||
self.chart.add(Account {
|
||
id,
|
||
code: code.to_string(),
|
||
name: name.to_string(),
|
||
account_type,
|
||
parent,
|
||
placeholder,
|
||
currency: self.base_currency,
|
||
tax_category: None,
|
||
deductible_percent: None,
|
||
})?;
|
||
self.next_account_id += 1;
|
||
Ok(id)
|
||
}
|
||
|
||
/// Give an account a new number (code).
|
||
///
|
||
/// ```text
|
||
/// GOAL: let the human renumber an account (say, off a code the engine
|
||
/// uses for a control role) without touching its history.
|
||
/// SOURCE: DECISION (owner, 2026-10-14: "fix the account number");
|
||
/// control accounts: Ledger::check_controls.
|
||
/// INVARIANTS:
|
||
/// 1. Only the code changes: the account's id, name, type, parent,
|
||
/// tax category, and every entry posted to it stay as they were
|
||
/// (entries name accounts by id), so every balance and report
|
||
/// total is unchanged.
|
||
/// 2. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the account is unknown, or the code is blank or already in
|
||
/// use (its own included): SHOULD refuse.
|
||
/// 2. IF the code is a control account's and the account is of
|
||
/// another type, or a placeholder: SHOULD refuse with
|
||
/// ControlAccountMismatch, as a control command would.
|
||
/// ```
|
||
pub fn renumber_account(&mut self, account: AccountId, code: &str) -> Result<(), LedgerError> {
|
||
self.check_renumber_account(account, code)?;
|
||
self.chart.set_code(account, code)
|
||
}
|
||
|
||
/// Give an account a new name: `name`, trimmed.
|
||
///
|
||
/// ```text
|
||
/// GOAL: let the human rename an account without touching its history.
|
||
/// SOURCE: rules.md B-31; DECISION (owner, plan/served.md 4.9 and P3
|
||
/// "Settled in design", 2026-10-20); rules.md B-8 (posted is
|
||
/// forever: entries name accounts by id, so a name is a label).
|
||
/// INVARIANTS:
|
||
/// 1. Only the name changes: the account's id, code, type, parent,
|
||
/// placeholder flag, tax category, and every entry posted to it
|
||
/// stay as they were, so no entry, balance or statement amount
|
||
/// changes; every report, past periods included, prints the new
|
||
/// name.
|
||
/// 2. A refusal MUST leave the books unchanged.
|
||
/// 3. The rules are the command's: replay takes any journaled name
|
||
/// for an account that exists (Self::replay_rename_account,
|
||
/// rules.md B-28).
|
||
/// SCENARIOS:
|
||
/// 1. IF the account is unknown: SHOULD refuse with UnknownAccount.
|
||
/// 2. IF the name is blank: SHOULD refuse with EmptyAccountName.
|
||
/// 3. IF the name, trimmed, is the one it has: SHOULD refuse with
|
||
/// AccountNameUnchanged (nothing to journal).
|
||
/// 4. IF another account under the same parent has it, ignoring
|
||
/// capitals and surrounding space: SHOULD refuse with
|
||
/// DuplicateAccountName.
|
||
/// 5. IF it holds a run of nine digits: SHOULD refuse with
|
||
/// AccountNameHoldsTin.
|
||
/// 6. IF it is longer than MAX_ACCOUNT_NAME characters or holds a
|
||
/// control character: SHOULD refuse with InvalidAccountName (the
|
||
/// rules a new account's name meets, Self::add_account).
|
||
/// 7. Approval rules, roles, settings and closed periods never
|
||
/// block it: they name accounts by code or id, and a name is no
|
||
/// posting.
|
||
/// ```
|
||
pub fn rename_account(&mut self, account: AccountId, name: &str) -> Result<(), LedgerError> {
|
||
self.check_rename_account(account, name)?;
|
||
self.chart.set_name(account, name.trim())
|
||
}
|
||
|
||
/// The validation behind [`Self::rename_account`], for stores that
|
||
/// journal before mutating.
|
||
pub fn check_rename_account(&self, account: AccountId, name: &str) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return Err(LedgerError::EmptyAccountName);
|
||
}
|
||
if name == a.name {
|
||
return Err(LedgerError::AccountNameUnchanged {
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
});
|
||
}
|
||
self.check_account_name(Some(account), name, a.parent)
|
||
}
|
||
|
||
/// Replay a journaled rename: the account must exist; the name is
|
||
/// taken as journaled, whatever later versions refuse in a new one
|
||
/// (rules.md B-28).
|
||
pub fn replay_rename_account(
|
||
&mut self,
|
||
account: AccountId,
|
||
name: &str,
|
||
) -> Result<(), LedgerError> {
|
||
self.chart.set_name(account, name)
|
||
}
|
||
|
||
/// Check a rename's reason before it is journaled: one to
|
||
/// [`crate::settings::MAX_LONG_TEXT`] characters, line breaks and tabs
|
||
/// allowed but no other control character, and no full TIN (rules.md
|
||
/// B-26). Returns it trimmed.
|
||
pub fn check_rename_reason(reason: &str) -> Result<String, LedgerError> {
|
||
let r = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||
.map_err(LedgerError::InvalidRenameReason)?;
|
||
if crate::parties::has_full_tin(&r) {
|
||
return Err(LedgerError::InvalidRenameReason(
|
||
"reason holds nine digits in a row, which read as a full SSN or EIN: write only the last four".into(),
|
||
));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Whether `policy.require_tax_category` lets an account be created
|
||
/// with `category` (rules.md B-32): under `refuse`, a revenue or
|
||
/// expense account that isn't a header needs one. The command's check
|
||
/// only, never replay's (rules.md B-28); the accounts the engine
|
||
/// creates for a control role never meet it.
|
||
pub fn check_tax_category_required(
|
||
&self,
|
||
code: &str,
|
||
name: &str,
|
||
account_type: AccountType,
|
||
placeholder: bool,
|
||
category: Option<&str>,
|
||
) -> Result<(), LedgerError> {
|
||
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
|
||
&& crate::tax::needs_category(account_type, placeholder)
|
||
&& category.is_none()
|
||
{
|
||
return Err(LedgerError::TaxCategoryRequired {
|
||
account: format!("{} {}", code.trim(), name.trim()),
|
||
clearing: false,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether `policy.require_tax_category` lets `account`'s category be
|
||
/// set to `category` (rules.md B-32): under `refuse`, an account that
|
||
/// needs one ([`crate::tax::account_needs_category`]) keeps one. The
|
||
/// command's check only, never replay's.
|
||
pub fn check_tax_category_kept(
|
||
&self,
|
||
account: AccountId,
|
||
category: Option<&str>,
|
||
) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
|
||
&& category.is_none()
|
||
&& crate::tax::account_needs_category(a)
|
||
{
|
||
return Err(LedgerError::TaxCategoryRequired {
|
||
account: format!("{} {}", a.code, a.name),
|
||
clearing: a.tax_category.is_some(),
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::renumber_account`], for stores that
|
||
/// journal before mutating.
|
||
pub fn check_renumber_account(
|
||
&self,
|
||
account: AccountId,
|
||
code: &str,
|
||
) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if code.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAccountCode);
|
||
}
|
||
if self.chart.find_by_code(code).is_some() {
|
||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||
}
|
||
// An approval rule names accounts by code: renumbering one it names
|
||
// would leave the rule covering nothing (or, later, another
|
||
// account). It is revised first (crate::conventions INVARIANT 3).
|
||
if let Some((rule, _)) = self
|
||
.conventions
|
||
.approval_rules()
|
||
.find(|(_, r)| r.as_ref().is_none_or(|r| r.accounts.contains(&a.code)))
|
||
{
|
||
return Err(LedgerError::InvalidConvention(format!(
|
||
"approval rule {} names account {}: retire it (books.retire_convention) or revise it without that account (books.set_convention), renumber, then set the rule again with the new code",
|
||
rule.id, a.code
|
||
)));
|
||
}
|
||
if let Some(c) =
|
||
control_accounts().find(|c| c.code == code && !self.roles.contains_key(c.key))
|
||
&& (a.account_type != c.account_type || a.placeholder)
|
||
{
|
||
return Err(LedgerError::ControlAccountMismatch {
|
||
code: code.to_string(),
|
||
name: a.name.clone(),
|
||
role: c.role.to_string(),
|
||
expected: c.account_type,
|
||
found: a.account_type,
|
||
placeholder: a.placeholder,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The account that fills a control role now: the one assigned to it,
|
||
/// else the account at the role's code (`None` when there is none yet;
|
||
/// the first command that needs it creates it).
|
||
pub fn role_account(&self, c: &ControlAccount) -> Option<AccountId> {
|
||
match self.roles.get(c.key) {
|
||
Some(&id) => Some(id),
|
||
None => self.chart.find_by_code(c.code).map(|a| a.id),
|
||
}
|
||
}
|
||
|
||
/// The account assigned to a control role, if any (`None`: the role
|
||
/// uses its code).
|
||
pub fn role_assignment(&self, c: &ControlAccount) -> Option<AccountId> {
|
||
self.roles.get(c.key).copied()
|
||
}
|
||
|
||
/// Assign a control role an account of the books' own numbering, or
|
||
/// (`None`) return it to its code. Invoices, bills, payroll, and closes
|
||
/// then post the role there.
|
||
///
|
||
/// ```text
|
||
/// GOAL: let a chart keep its own numbering: the engine's roles go to
|
||
/// the accounts the human picks, not only to fixed codes.
|
||
/// SOURCE: docs/spec/rules.md B-4 (the elements), B-23 (control roles);
|
||
/// DECISION (owner, 2026-10-15): roles are journaled, not
|
||
/// configured, so replay never depends on a file.
|
||
/// INVARIANTS:
|
||
/// 1. After an assignment, every command that posts the role posts it
|
||
/// to that account; after `None`, to the account at the role's
|
||
/// code (created when first needed, as before).
|
||
/// 2. No account fills two roles.
|
||
/// 3. A role's account is always postable and of the role's type.
|
||
/// 4. Posted entries never change: an assignment only steers what
|
||
/// later commands post.
|
||
/// 5. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the role is unknown, or the account is unknown: SHOULD refuse.
|
||
/// 2. IF the account (or, for `None`, the account at the role's
|
||
/// code) is of another type or a placeholder: SHOULD refuse with
|
||
/// ControlAccountMismatch.
|
||
/// 3. IF that account already fills another role: SHOULD refuse
|
||
/// with RoleTaken.
|
||
/// 4. IF the role is a balance sheet one (an asset, liability, or
|
||
/// equity role) and the account filling it now holds a balance,
|
||
/// and the role would move: SHOULD refuse with RoleHoldsBalance
|
||
/// BECAUSE the control account must keep agreeing with what it
|
||
/// controls (open invoices, bills, taxes withheld); move the
|
||
/// balance with an entry first. Revenue and expense roles move
|
||
/// freely.
|
||
/// - For receivables and payables: DocumentRoleHoldsBalance
|
||
/// instead, which names no hand entry (the control guard
|
||
/// refuses one, B-38): their balance moves with its documents,
|
||
/// a later command (plan P4b S7). Only on their control account
|
||
/// (Self::control_role_of): the account at the default code,
|
||
/// before any document posted there, is the chart's own, and
|
||
/// its balance no document's.
|
||
/// 5. IF the role already resolves to that account: SHOULD accept
|
||
/// (the assignment is recorded).
|
||
/// 6. IF the role is receivables or payables and the account it
|
||
/// would move to holds a posted line that is no document's:
|
||
/// - SHOULD refuse (RoleAccountHasHandLines), naming the entry
|
||
/// - NEVER make a hand line a control account's
|
||
/// BECAUSE no command adds an untagged control line (B-38
|
||
/// INVARIANT 1). Replay never refuses (B-28).
|
||
/// An account the role moves off keeps its documents' lines, and
|
||
/// stays guarded while it holds them (Self::control_role_of).
|
||
/// ```
|
||
pub fn assign_role(
|
||
&mut self,
|
||
key: &str,
|
||
account: Option<AccountId>,
|
||
) -> Result<(), LedgerError> {
|
||
let c = self.check_assign_role(key, account)?;
|
||
match account {
|
||
Some(id) => self.roles.insert(c.key, id),
|
||
None => self.roles.remove(c.key),
|
||
};
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::assign_role`], for stores that journal
|
||
/// before mutating. Returns the role.
|
||
pub fn check_assign_role(
|
||
&self,
|
||
key: &str,
|
||
account: Option<AccountId>,
|
||
) -> Result<&'static ControlAccount, LedgerError> {
|
||
let c = control_account(key).ok_or_else(|| LedgerError::UnknownRole(key.to_string()))?;
|
||
let target = match account {
|
||
Some(id) => Some(self.chart.get(id).ok_or(LedgerError::UnknownAccount(id))?),
|
||
None => self.chart.find_by_code(c.code),
|
||
};
|
||
if let Some(a) = target {
|
||
if a.account_type != c.account_type || a.placeholder {
|
||
return Err(LedgerError::ControlAccountMismatch {
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
role: c.role.to_string(),
|
||
expected: c.account_type,
|
||
found: a.account_type,
|
||
placeholder: a.placeholder,
|
||
});
|
||
}
|
||
if let Some(other) =
|
||
control_accounts().find(|o| o.key != c.key && self.role_account(o) == Some(a.id))
|
||
{
|
||
return Err(LedgerError::RoleTaken {
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
role: other.role.to_string(),
|
||
});
|
||
}
|
||
}
|
||
let now = self.role_account(c);
|
||
let documents = c.key == RECEIVABLES.key || c.key == PAYABLES.key;
|
||
// SCENARIO 6: the new account's lines must all be documents'.
|
||
if documents
|
||
&& !crate::parties::is_replaying()
|
||
&& let Some(a) = target
|
||
&& now != Some(a.id)
|
||
&& let Some(entry) = self.posted_entries().find_map(|e| {
|
||
e.lines()
|
||
.iter()
|
||
.enumerate()
|
||
.any(|(i, l)| l.account == a.id && !self.line_docs.contains_key(&(e.id(), i)))
|
||
.then_some(e.id())
|
||
})
|
||
{
|
||
return Err(LedgerError::RoleAccountHasHandLines {
|
||
role: c.role.to_string(),
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
entry,
|
||
});
|
||
}
|
||
let balance_sheet = !matches!(c.account_type, AccountType::Revenue | AccountType::Expense);
|
||
// A document role's balance is its documents' only on its control
|
||
// account (Self::control_role_of): the account at its default
|
||
// code before any document posted there is a chart's own.
|
||
let controls = |current: AccountId| {
|
||
!documents
|
||
|| self
|
||
.control_role_of(current)
|
||
.is_some_and(|r| r.key == c.key)
|
||
};
|
||
if let Some(current) = now
|
||
&& balance_sheet
|
||
&& now != target.map(|a| a.id)
|
||
&& controls(current)
|
||
{
|
||
let balance = self.account_balance(current).unwrap_or(Decimal::ZERO);
|
||
if !balance.is_zero() {
|
||
let a = self.chart.get(current).expect("a role's account exists");
|
||
if documents {
|
||
return Err(LedgerError::DocumentRoleHoldsBalance {
|
||
role: c.role.to_string(),
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
balance,
|
||
});
|
||
}
|
||
return Err(LedgerError::RoleHoldsBalance {
|
||
role: c.role.to_string(),
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
balance,
|
||
});
|
||
}
|
||
}
|
||
Ok(c)
|
||
}
|
||
|
||
/// Set (or clear, with `None`) an account's tax category, and optionally
|
||
/// override the category's deductible percent (0 to 100). The category
|
||
/// must fit the account's type (see [`crate::tax::check_category`]).
|
||
pub fn set_account_tax(
|
||
&mut self,
|
||
account: AccountId,
|
||
category: Option<&str>,
|
||
deductible_percent: Option<Decimal>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_account_tax(account, category, deductible_percent)?;
|
||
self.chart
|
||
.set_tax(account, category.map(str::to_string), deductible_percent)
|
||
}
|
||
|
||
/// The validation behind [`Self::set_account_tax`], for stores that
|
||
/// journal before mutating.
|
||
pub fn check_account_tax(
|
||
&self,
|
||
account: AccountId,
|
||
category: Option<&str>,
|
||
deductible_percent: Option<Decimal>,
|
||
) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if let Some(key) = category {
|
||
crate::tax::check_category(key, a.account_type, None)?;
|
||
} else if deductible_percent.is_some() {
|
||
return Err(LedgerError::InvalidTaxCategory(
|
||
"a deductible percent needs a tax category".to_string(),
|
||
));
|
||
}
|
||
if let Some(p) = deductible_percent
|
||
&& (p < Decimal::ZERO || p > Decimal::from(100))
|
||
{
|
||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||
"deductible percent {p} must be between 0 and 100"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Start a draft journal entry. Returns its id.
|
||
///
|
||
/// This is the unchecked constructor: replay and internal callers
|
||
/// (closing entries) use it. For the checked version that rejects
|
||
/// dates in closed periods, see [`Self::try_create_entry`].
|
||
pub fn create_entry(&mut self, date: NaiveDate, description: &str) -> EntryId {
|
||
let id = self.next_entry_id;
|
||
self.next_entry_id += 1;
|
||
self.entries
|
||
.insert(id, JournalEntry::draft(id, date, description.to_string()));
|
||
id
|
||
}
|
||
|
||
/// Start a draft journal entry, rejecting dates that fall in a closed
|
||
/// period. This is the entry point for new work; [`Self::create_entry`]
|
||
/// is for replay and internal use.
|
||
///
|
||
/// ```text
|
||
/// GOAL: start a draft entry for new work.
|
||
/// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen.
|
||
/// SCENARIOS:
|
||
/// 0. IF the date is outside the calendar the books keep
|
||
/// (crate::calendar): SHOULD refuse (DateOutOfRange).
|
||
/// 1. IF the date falls in a closed period:
|
||
/// - SHOULD refuse (ClosedPeriod)
|
||
/// - NEVER create a draft there
|
||
/// BECAUSE a closed period takes no new entries.
|
||
/// 2. Otherwise: SHOULD return a fresh id, one past the last.
|
||
/// ```
|
||
pub fn try_create_entry(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
description: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
crate::calendar::check(date)?;
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
Ok(self.create_entry(date, description))
|
||
}
|
||
|
||
/// The id of a closed period containing `date`, if any.
|
||
///
|
||
/// Pure query. Journaling stores use it to pre-validate dates before
|
||
/// journaling, so a journal write is never followed by a failed mutation.
|
||
pub fn closed_period_containing(&self, date: NaiveDate) -> Option<PeriodId> {
|
||
self.periods
|
||
.values()
|
||
.find(|p| p.status == PeriodStatus::Closed && p.contains(date))
|
||
.map(|p| p.id)
|
||
}
|
||
|
||
fn draft_mut(&mut self, entry: EntryId) -> Result<&mut JournalEntry, LedgerError> {
|
||
let e = self
|
||
.entries
|
||
.get_mut(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
if e.status() != EntryStatus::Draft {
|
||
return Err(LedgerError::PostedEntryImmutable);
|
||
}
|
||
Ok(e)
|
||
}
|
||
|
||
/// Add a line to a draft entry. The amount must be in the ledger's base
|
||
/// currency; drafts reject anything else rather than converting.
|
||
pub fn add_line(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
amount: Money,
|
||
) -> Result<(), LedgerError> {
|
||
self.add_line_journaled(entry, account, side, amount, true)
|
||
}
|
||
|
||
/// [`Self::add_line`]'s checks: the account, the minor unit, and (when
|
||
/// `bounded`, for new work) the size. A draft line larger than any
|
||
/// posting could carry is refused when it is added, not only at the
|
||
/// post (P0 review round 3, S1); a recurring entry's draft is generated
|
||
/// with the checks its template passed when it was journaled
|
||
/// (`bounded` false), so a journaled `recurring.generated` always
|
||
/// replays (rules.md B-28).
|
||
fn add_line_journaled(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
amount: Money,
|
||
bounded: bool,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_line_account(account)?;
|
||
self.check_hand_line(entry, account)?;
|
||
check_minor_units(amount)?;
|
||
if bounded {
|
||
check_line_size(amount.amount)?;
|
||
}
|
||
self.replay_add_line(entry, account, side, amount)
|
||
}
|
||
|
||
/// The document role an account is the control account of, if any:
|
||
/// receivables or payables (rules.md B-23, B-38; a later control role
|
||
/// joins them). An account is one when the role is assigned to it
|
||
/// ([`Self::assign_role`]), or when it holds a line of one of the
|
||
/// role's documents (the role's default code, once a document has
|
||
/// posted there; an account the role has moved off, whose documents'
|
||
/// lines stay on it).
|
||
///
|
||
/// ```text
|
||
/// GOAL: guard exactly the accounts whose balance is documents, and
|
||
/// no other: a chart's own account at a role's default code
|
||
/// (1200 Equipment, a card at 2000) is anyone's until a document
|
||
/// posts there.
|
||
/// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1.
|
||
/// INVARIANTS:
|
||
/// 1. Some(role) when the role is assigned to the account, or the
|
||
/// account holds a line tagged with one of the role's documents
|
||
/// (an invoice's for receivables, a bill's for payables).
|
||
/// 2. None for every other account, the default code's included.
|
||
/// ```
|
||
pub fn control_role_of(&self, account: AccountId) -> Option<&'static ControlAccount> {
|
||
[(&RECEIVABLES, true), (&PAYABLES, false)]
|
||
.into_iter()
|
||
.find(|&(c, invoices)| {
|
||
self.roles.get(c.key) == Some(&account)
|
||
|| self.doc_accounts.contains(&(account, invoices))
|
||
})
|
||
.map(|(c, _)| c)
|
||
}
|
||
|
||
/// The accounts that are a document role's control accounts
|
||
/// ([`Self::control_role_of`]), in account order.
|
||
fn document_role_accounts(&self, c: &ControlAccount) -> Vec<AccountId> {
|
||
let invoices = c.key == RECEIVABLES.key;
|
||
let mut out: Vec<AccountId> = self
|
||
.doc_accounts
|
||
.iter()
|
||
.filter(|&&(_, i)| i == invoices)
|
||
.map(|&(a, _)| a)
|
||
.collect();
|
||
if let Some(&a) = self.roles.get(c.key)
|
||
&& !out.contains(&a)
|
||
{
|
||
out.push(a);
|
||
out.sort_unstable();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Refuse a line by hand on a control account (the control guard,
|
||
/// plan P4b.3): such a line is the document commands' alone, so the
|
||
/// account always equals its documents. Replay never refuses (B-28).
|
||
fn check_hand_line(&self, entry: EntryId, account: AccountId) -> Result<(), LedgerError> {
|
||
if crate::parties::is_replaying() || self.control_role_of(account).is_none() {
|
||
return Ok(());
|
||
}
|
||
let line = self.entries.get(&entry).map_or(0, |e| e.lines().len());
|
||
Err(LedgerError::UntaggedControlLine {
|
||
account: self.account_label(account),
|
||
at: format!("line {line} of entry {entry}"),
|
||
})
|
||
}
|
||
|
||
/// An account as a refusal names it: `1200 "Accounts Receivable"`.
|
||
fn account_label(&self, account: AccountId) -> String {
|
||
self.chart.get(account).map_or_else(
|
||
|| account.to_string(),
|
||
|a| format!("{} \"{}\"", a.code, a.name),
|
||
)
|
||
}
|
||
|
||
/// A document command's line on a control account: [`Self::add_line`]'s
|
||
/// checks but the control guard's, then tagged with its document in
|
||
/// the same step (so the guard at the post finds it tagged).
|
||
fn add_tagged_line(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
amount: Money,
|
||
tag: arap::DocTag,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_line_account(account)?;
|
||
check_minor_units(amount)?;
|
||
check_line_size(amount.amount)?;
|
||
self.replay_add_line(entry, account, side, amount)?;
|
||
let idx = self.entries[&entry].lines().len() - 1;
|
||
self.tag_line(entry, idx, tag);
|
||
Ok(())
|
||
}
|
||
|
||
/// Tag a ledger line with its document, keeping the indexes read from
|
||
/// the tags (`doc_index`, `doc_accounts`) in step. The only way a tag
|
||
/// is written; tags are never removed (a refused command's are rolled
|
||
/// back with the rest of the ledger by [`Self::atomically`]).
|
||
fn tag_line(&mut self, entry: EntryId, idx: usize, tag: arap::DocTag) {
|
||
if let Some(l) = self.entries.get(&entry).and_then(|e| e.lines().get(idx)) {
|
||
self.doc_accounts
|
||
.insert((l.account, matches!(tag.doc, arap::DocRef::Invoice(_))));
|
||
}
|
||
self.doc_index
|
||
.entry(tag.doc)
|
||
.or_default()
|
||
.insert((entry, idx));
|
||
self.line_docs.insert((entry, idx), tag);
|
||
}
|
||
|
||
/// The control guard at the post (plan P4b.3): every line of `entry` on
|
||
/// a receivables or payables account carries its document, but the
|
||
/// mirror of an untagged line in a reversal that takes it off the
|
||
/// books (reversing an old hand line is how it is cleaned up).
|
||
/// Replay never refuses (B-28).
|
||
///
|
||
/// ```text
|
||
/// GOAL: no command adds a line on a control account that is no
|
||
/// document's; one only takes a legacy one away.
|
||
/// SOURCE: docs/spec/rules.md B-38 INVARIANT 1 and item 3 (the
|
||
/// exemption).
|
||
/// INVARIANTS:
|
||
/// 1. A tagged line passes.
|
||
/// 2. An untagged line passes only as the mirror of an untagged line
|
||
/// on the same account in the entry it reverses, when that entry
|
||
/// stands at an even place in its reversal chain (the hand line
|
||
/// itself, or a reversal of its reversal): the mirror removes the
|
||
/// line. Reversing the hand line's reversal would put it back, and
|
||
/// is refused.
|
||
/// ```
|
||
fn check_control_lines(&self, entry: &JournalEntry) -> Result<(), LedgerError> {
|
||
if crate::parties::is_replaying() {
|
||
return Ok(());
|
||
}
|
||
for (i, l) in entry.lines().iter().enumerate() {
|
||
if self.control_role_of(l.account).is_none()
|
||
|| self.line_docs.contains_key(&(entry.id(), i))
|
||
{
|
||
continue;
|
||
}
|
||
let removes_untagged = entry.reversal_of().is_some_and(|o| {
|
||
self.entries
|
||
.get(&o)
|
||
.and_then(|o| o.lines().get(i))
|
||
.is_some_and(|ol| ol.account == l.account)
|
||
&& !self.line_docs.contains_key(&(o, i))
|
||
&& self
|
||
.reversal_chain(o)
|
||
.iter()
|
||
.position(|&e| e == o)
|
||
.is_some_and(|at| at % 2 == 0)
|
||
});
|
||
if !removes_untagged {
|
||
return Err(LedgerError::UntaggedControlLine {
|
||
account: self.account_label(l.account),
|
||
at: format!("line {i} of entry {}", entry.id()),
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A line's account must exist and take postings, checked when the
|
||
/// line is added (owner, 2026-10-13); posting checks again.
|
||
fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if a.placeholder {
|
||
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::add_line`] without the account check added on 2026-10-13 or
|
||
/// the size bound added in P0, for replaying journals written before
|
||
/// them (rules.md B-28): a draft line replays as journaled, and its post
|
||
/// refuses it if it is out of range. Never for new work.
|
||
pub fn replay_add_line(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
amount: Money,
|
||
) -> Result<(), LedgerError> {
|
||
if amount.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: amount.currency.code().to_string(),
|
||
});
|
||
}
|
||
let e = self.draft_mut(entry)?;
|
||
e.push_line(JournalLine {
|
||
account,
|
||
side,
|
||
amount,
|
||
foreign_currency: None,
|
||
foreign_amount: None,
|
||
class_id: None,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// Add a foreign-currency line to a draft entry. `foreign_amount` is the
|
||
/// amount in `foreign_currency`; `fx_rate` converts foreign -> base.
|
||
/// The stored `amount` is foreign * rate rounded to the base currency's
|
||
/// minor unit (cents for USD), half away from zero; the foreign
|
||
/// original is kept for reference. Rates are exact Decimals, never
|
||
/// floats.
|
||
///
|
||
/// ```text
|
||
/// GOAL: book a foreign-currency amount in the ledger's one money.
|
||
/// SOURCE: docs/spec/rules.md B-3 (Pacioli ch. 36: one money; ASC
|
||
/// 830-20-30-1: at the transaction-date rate); minor units: ISO
|
||
/// 4217; rounding: DECISION (owner, 2026-10-13).
|
||
/// INVARIANTS:
|
||
/// 1. The base amount MUST be a whole number of the base currency's
|
||
/// smallest unit, within half a unit of foreign * rate.
|
||
/// 2. NEVER convert into the base currency itself.
|
||
/// SCENARIOS:
|
||
/// 1. IF 100.00 EUR at 1.0834567 into USD: SHOULD book 108.35.
|
||
/// 2. IF 10.00 USD at 151.237 into JPY: SHOULD book 1512.
|
||
/// 3. IF the rounded amount is zero: SHOULD refuse.
|
||
/// ```
|
||
pub fn add_line_fx(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
foreign_currency: Currency,
|
||
foreign_amount: Decimal,
|
||
fx_rate: Decimal,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_line_account(account)?;
|
||
self.check_hand_line(entry, account)?;
|
||
if foreign_amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount));
|
||
}
|
||
if fx_rate <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidFxRate(fx_rate));
|
||
}
|
||
if foreign_currency == self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: "foreign currency".to_string(),
|
||
found: foreign_currency.code().to_string(),
|
||
});
|
||
}
|
||
let exact = foreign_amount
|
||
.checked_mul(fx_rate)
|
||
.ok_or(LedgerError::AmountOutOfRange)?;
|
||
// Booked in the base currency's smallest unit (cents for USD), half
|
||
// away from zero: a ledger in one money holds no fractions of its
|
||
// smallest unit (rules.md B-3; DECISION, owner 2026-10-13, after
|
||
// the GnuCash cross-check found the difference).
|
||
let base_amount = match self.base_currency.minor_units() {
|
||
Some(dp) => exact
|
||
.round_dp_with_strategy(dp, rust_decimal::RoundingStrategy::MidpointAwayFromZero),
|
||
None => exact,
|
||
};
|
||
if base_amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidFxRate(fx_rate));
|
||
}
|
||
check_line_size(base_amount)?;
|
||
self.replay_add_line_fx(
|
||
entry,
|
||
account,
|
||
side,
|
||
Money::new(base_amount, self.base_currency),
|
||
foreign_currency,
|
||
foreign_amount,
|
||
)
|
||
}
|
||
|
||
/// A foreign-currency line exactly as a journal recorded it: the base
|
||
/// amount as booked (rounded or not, by the rule of its day, and of any
|
||
/// size: the bound is the command's, rules.md B-28) and the foreign
|
||
/// original. For replay only; new work uses [`Self::add_line_fx`].
|
||
pub fn replay_add_line_fx(
|
||
&mut self,
|
||
entry: EntryId,
|
||
account: AccountId,
|
||
side: Side,
|
||
amount: Money,
|
||
foreign_currency: Currency,
|
||
foreign_amount: Decimal,
|
||
) -> Result<(), LedgerError> {
|
||
if amount.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: amount.currency.code().to_string(),
|
||
});
|
||
}
|
||
let e = self.draft_mut(entry)?;
|
||
e.push_line(JournalLine {
|
||
account,
|
||
side,
|
||
amount,
|
||
foreign_currency: Some(foreign_currency),
|
||
foreign_amount: Some(foreign_amount),
|
||
class_id: None,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate and post a draft entry. One-way: a posted entry is immutable.
|
||
///
|
||
/// Rejects entries dated in a closed period.
|
||
///
|
||
/// ```text
|
||
/// GOAL: make a draft part of the books, once and for good.
|
||
/// SOURCE: docs/spec/rules.md B-1, B-2, B-3, B-8 (Pacioli ch. 36: write
|
||
/// only forward), B-12.
|
||
/// INVARIANTS:
|
||
/// 1. MUST post only an entry that passes JournalEntry::validate.
|
||
/// 2. MUST append it to the log; NEVER reorder, change, or remove an
|
||
/// earlier posted entry.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry is unknown: SHOULD refuse (UnknownEntry).
|
||
/// 2. IF it is already posted:
|
||
/// - SHOULD refuse (EntryAlreadyPosted)
|
||
/// - NEVER post it twice
|
||
/// 3. IF it is dated in a closed period: SHOULD refuse (ClosedPeriod).
|
||
/// 4. IF it doesn't balance, has fewer than two lines, a zero, negative,
|
||
/// or out-of-range amount, or an unknown or placeholder account:
|
||
/// SHOULD refuse with that reason.
|
||
/// 5. IF a line is finer than its currency's minor unit (a fraction
|
||
/// of a cent): SHOULD refuse (FinerThanMinorUnit)
|
||
/// BECAUSE a ledger in one money holds no fractions of its
|
||
/// smallest unit (B-3); found when a payroll run posted $65.27794
|
||
/// (the store's refusal property in CI, 2026-10-13).
|
||
/// 6. IF a line is on a receivables or payables account (the role's
|
||
/// account now) and carries no document:
|
||
/// - SHOULD refuse (UntaggedControlLine), naming the document
|
||
/// tools
|
||
/// - NEVER refuse the mirror of an untagged line in a reversal
|
||
/// BECAUSE a control account must equal its documents (B-23,
|
||
/// B-38): only the document commands post there, and reversing
|
||
/// an old hand line is how it is cleaned up. Replay never
|
||
/// refuses (B-28).
|
||
/// ```
|
||
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> {
|
||
if let Some(e) = self.entries.get(&entry) {
|
||
for line in e.lines() {
|
||
check_minor_units(line.amount)?;
|
||
}
|
||
if e.status() == EntryStatus::Draft {
|
||
self.check_control_lines(e)?;
|
||
}
|
||
}
|
||
self.replay_post_entry(entry)
|
||
}
|
||
|
||
/// [`Self::post_entry`] without the minor-unit check added on
|
||
/// 2026-10-13, for replaying journals written before it. Never for new
|
||
/// work.
|
||
pub fn replay_post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> {
|
||
let snapshot = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?
|
||
.clone();
|
||
if snapshot.status() == EntryStatus::Posted {
|
||
return Err(LedgerError::EntryAlreadyPosted(entry));
|
||
}
|
||
// Closed periods reject new postings.
|
||
if let Some(pid) = self.closed_period_containing(snapshot.date()) {
|
||
return Err(LedgerError::ClosedPeriod(pid, snapshot.date().to_string()));
|
||
}
|
||
// Clone so validation borrows end before the mutable mark.
|
||
snapshot.validate(&self.chart)?;
|
||
self.entries
|
||
.get_mut(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?
|
||
.mark_posted();
|
||
self.order_pos.insert(entry, self.order.len());
|
||
self.order.push(entry);
|
||
if let Some(o) = snapshot.reversal_of() {
|
||
self.reversed_by.entry(o).or_insert(entry);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Look up an entry by id (draft or posted).
|
||
pub fn get_entry(&self, entry: EntryId) -> Option<&JournalEntry> {
|
||
self.entries.get(&entry)
|
||
}
|
||
|
||
/// Pure pre-check for [`Self::reverse_entry`]: every way a reversal can
|
||
/// be refused, checked without touching the books, so journaling stores
|
||
/// can validate before they journal.
|
||
///
|
||
/// ```text
|
||
/// GOAL: decide whether `entry` may be reversed on `date`.
|
||
/// SOURCE: docs/spec/rules.md B-9 (Pacioli ch. 31, 36), B-10 (DECISION,
|
||
/// owner 2026-10-13), B-12 (closed periods).
|
||
/// INVARIANTS:
|
||
/// 1. MUST NOT mutate anything.
|
||
/// 2. NEVER accepts a reversal that `reverse_entry` would then refuse.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry is unknown or still a draft:
|
||
/// - SHOULD refuse (`UnknownEntry`, `EntryNotPosted`)
|
||
/// BECAUSE only posted entries are in the books to correct.
|
||
/// 2. IF the entry was already reversed:
|
||
/// - SHOULD refuse with `EntryAlreadyReversed(entry, by)`
|
||
/// - NEVER undo it a second time
|
||
/// BECAUSE a second reversal would undo the original twice
|
||
/// (owner, 2026-10-13). Reversing the reversal re-instates it.
|
||
/// 3. IF the entry closes a period:
|
||
/// - SHOULD refuse with `ClosingEntryIrreversible`
|
||
/// BECAUSE the period would stay closed with its earnings
|
||
/// unswept (owner, 2026-10-13).
|
||
/// 4. IF `date` falls in a closed period:
|
||
/// - SHOULD refuse with `ClosedPeriod`
|
||
/// BECAUSE closed periods take no new postings.
|
||
/// 5. IF the entry reverses a schedule month's posting (so reversing
|
||
/// it re-instates that posting) and the month has been posted
|
||
/// again since:
|
||
/// - SHOULD refuse (InvalidSchedule), naming the entry that posts
|
||
/// it now
|
||
/// - NEVER recognize one month of a schedule twice
|
||
/// BECAUSE a reversed month is due again and may have been run
|
||
/// again (P3 depreciation review R3).
|
||
/// 6. IF the entry carries a document's lines (an invoice's or a
|
||
/// bill's own entry, a payment, or a reversal of one) and `date`
|
||
/// is before the entry's own date:
|
||
/// - SHOULD refuse (DocumentReversalBeforeOriginal)
|
||
/// BECAUSE a correction comes after what it corrects (B-9; plan
|
||
/// P4b.3, "Dating rules").
|
||
/// 7. IF reversing it would leave a document it carries owing less
|
||
/// than nothing or more than its total at some date (a void
|
||
/// under a payment that stands; re-instating a payment beside
|
||
/// another), where it didn't before:
|
||
/// - SHOULD refuse (DocumentBounds), naming the entries to
|
||
/// reverse first
|
||
/// - NEVER refuse reversing a payment, which only reopens its
|
||
/// document
|
||
/// BECAUSE 0 <= outstanding <= total at every date (B-38
|
||
/// INVARIANT 2). Replay never refuses (B-28).
|
||
/// ```
|
||
pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> {
|
||
let original = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
if original.status() != EntryStatus::Posted {
|
||
return Err(LedgerError::EntryNotPosted(entry));
|
||
}
|
||
if let Some(&by) = self.reversed_by.get(&entry) {
|
||
return Err(LedgerError::EntryAlreadyReversed(entry, by));
|
||
}
|
||
if self.closing_entry_ids().contains(&entry) {
|
||
return Err(LedgerError::ClosingEntryIrreversible(entry));
|
||
}
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
// SCENARIO 5. `entry` ends its chain (it isn't reversed), so
|
||
// reversing it flips whether the chain's original stands.
|
||
if let Some(top) = self.schedule_chain_top(entry)
|
||
&& !self.in_effect(top)
|
||
&& let Some((s, month)) = self.schedule_month_of(top)
|
||
&& let Some(now) = s.postings[&month]
|
||
.iter()
|
||
.find(|&&e| e != top && self.in_effect(e))
|
||
{
|
||
return Err(LedgerError::InvalidSchedule(format!(
|
||
"reversing entry {entry} would post schedule {}'s {month} a second time: entry {now} posts it now (reverse that one instead)",
|
||
s.id
|
||
)));
|
||
}
|
||
if !crate::parties::is_replaying() {
|
||
self.check_document_reversal(original, date)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::check_reversal`] SCENARIOS 6 and 7: a reversal of an entry
|
||
/// carrying documents' lines.
|
||
fn check_document_reversal(
|
||
&self,
|
||
original: &JournalEntry,
|
||
date: NaiveDate,
|
||
) -> Result<(), LedgerError> {
|
||
let mut moves: Vec<(arap::DocRef, Decimal)> = Vec::new();
|
||
for (i, l) in original.lines().iter().enumerate() {
|
||
if let Some(tag) = self.line_docs.get(&(original.id(), i)) {
|
||
// The mirror moves the document by the opposite.
|
||
let by = -tag.doc.counted(l.side, l.amount.amount);
|
||
match moves.iter_mut().find(|(d, _)| *d == tag.doc) {
|
||
Some((_, m)) => *m += by,
|
||
None => moves.push((tag.doc, by)),
|
||
}
|
||
}
|
||
}
|
||
let Some(&(first, _)) = moves.first() else {
|
||
return Ok(());
|
||
};
|
||
if date < original.date() {
|
||
return Err(LedgerError::DocumentReversalBeforeOriginal {
|
||
entry: original.id(),
|
||
document: first.to_string(),
|
||
original: original.date(),
|
||
date,
|
||
});
|
||
}
|
||
for (doc, by) in moves {
|
||
self.check_item_bounds(doc, &[(date, by)])?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The original an entry's reversal chain starts from (the entry itself
|
||
/// when it reverses nothing), when some schedule month was ever posted:
|
||
/// books without one skip the chain walk.
|
||
fn schedule_chain_top(&self, entry: EntryId) -> Option<EntryId> {
|
||
if self.schedules.values().all(|s| s.postings.is_empty()) {
|
||
return None;
|
||
}
|
||
Some(self.reversal_chain(entry).first().copied().unwrap_or(entry))
|
||
}
|
||
|
||
/// Whether a posted entry's effect stands: nothing reverses it, or what
|
||
/// reverses it is itself reversed, and so on (an even number of
|
||
/// reversals after it in its chain). A draft or an unknown id stands
|
||
/// for nothing.
|
||
///
|
||
/// ```text
|
||
/// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an
|
||
/// opposite entry), B-10 (reversed at most once; reversing the
|
||
/// reversal re-instates it).
|
||
/// ```
|
||
pub fn in_effect(&self, entry: EntryId) -> bool {
|
||
self.stands_on(entry, NaiveDate::MAX)
|
||
}
|
||
|
||
/// Whether a posted entry's effect stands as of a day: it is dated on
|
||
/// or before `as_of`, and of the reversals after it in its chain, an
|
||
/// even number are dated on or before `as_of` (none, or one reversed
|
||
/// again, and so on). A draft or an unknown id stands for nothing.
|
||
///
|
||
/// ```text
|
||
/// GOAL: say whether an entry counts on a given day, so a document's
|
||
/// status (a void) and its aging are right as of any date.
|
||
/// SOURCE: docs/spec/rules.md B-9, B-10, B-38; plan/served.md P4b.3
|
||
/// ("Standing as of a date").
|
||
/// INVARIANTS:
|
||
/// 1. stands_on(e, NaiveDate::MAX) is in_effect(e).
|
||
/// 2. NEVER true for an entry dated after `as_of`, a draft, or an
|
||
/// unknown id.
|
||
/// 3. A reversal dated after `as_of` doesn't count against it, nor
|
||
/// does any later link of its chain (a re-reversal an older
|
||
/// journal dated before the reversal it undoes counts from that
|
||
/// reversal's date).
|
||
/// SCENARIOS:
|
||
/// 1. IF a payment is reversed on the 25th: SHOULD stand as of the
|
||
/// 22nd, and not as of the 31st.
|
||
/// ```
|
||
pub fn stands_on(&self, entry: EntryId, as_of: NaiveDate) -> bool {
|
||
if self
|
||
.entries
|
||
.get(&entry)
|
||
.is_none_or(|e| e.status() != EntryStatus::Posted || e.date() > as_of)
|
||
{
|
||
return false;
|
||
}
|
||
let chain = self.reversal_chain(entry);
|
||
let Some(i) = chain.iter().position(|&e| e == entry) else {
|
||
return false;
|
||
};
|
||
// A reversal counts only once what it reverses counts: an older
|
||
// journal may date a re-reversal before the reversal it undoes,
|
||
// and it can't undo what hasn't happened yet.
|
||
chain[i + 1..]
|
||
.iter()
|
||
.take_while(|r| self.entries.get(r).is_some_and(|r| r.date() <= as_of))
|
||
.count()
|
||
.is_multiple_of(2)
|
||
}
|
||
|
||
/// The schedule and month an entry posted, if it posted one (a
|
||
/// schedule month's posting; not its reversal).
|
||
pub fn schedule_posting(&self, entry: EntryId) -> Option<(ScheduleId, NaiveDate)> {
|
||
self.schedule_month_of(entry).map(|(s, m)| (s.id, m))
|
||
}
|
||
|
||
/// The schedule and month an entry posted, if it posted one.
|
||
fn schedule_month_of(&self, entry: EntryId) -> Option<(&AmortizationSchedule, NaiveDate)> {
|
||
self.schedules.values().find_map(|s| {
|
||
s.postings
|
||
.iter()
|
||
.find(|(_, es)| es.contains(&entry))
|
||
.map(|(m, _)| (s, *m))
|
||
})
|
||
}
|
||
|
||
/// Post a reversing entry: same lines with sides flipped, linked back to
|
||
/// the original via `reversal_of`. The books never forget; they correct.
|
||
/// Refused (see [`Self::check_reversal`]) before anything changes.
|
||
pub fn reverse_entry(
|
||
&mut self,
|
||
entry: EntryId,
|
||
date: NaiveDate,
|
||
description: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.atomically(|l| l.reverse_entry_steps(entry, date, description))
|
||
}
|
||
|
||
/// [`Self::reverse_entry`] as a journal recorded it: without the
|
||
/// checks later versions added for documents (a reversal's date, a
|
||
/// document's bounds, the control guard), for replaying journals
|
||
/// written before them (rules.md B-28). Never for new work.
|
||
pub fn replay_reverse_entry(
|
||
&mut self,
|
||
entry: EntryId,
|
||
date: NaiveDate,
|
||
description: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
crate::parties::replaying(|| self.reverse_entry(entry, date, description))
|
||
}
|
||
|
||
/// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`].
|
||
fn reverse_entry_steps(
|
||
&mut self,
|
||
entry: EntryId,
|
||
date: NaiveDate,
|
||
description: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.check_reversal(entry, date)?;
|
||
let original = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?
|
||
.clone();
|
||
let id = self.create_entry(date, description);
|
||
for (idx, line) in original.lines().iter().enumerate() {
|
||
// The mirror of each line, whatever its account: the control
|
||
// guard at the post below exempts the mirror of an untagged
|
||
// control line, and a tagged one carries its tag.
|
||
self.check_line_account(line.account)?;
|
||
self.replay_add_line(id, line.account, kernel::flip(line.side), line.amount)?;
|
||
// The reversal offsets the original line for line: carry the
|
||
// class so divisional P&L nets correctly, and the document so
|
||
// it moves by exactly the opposite (B-38 INVARIANT 3).
|
||
if line.class_id.is_some() {
|
||
self.set_line_class(id, idx, line.class_id)?;
|
||
}
|
||
if let Some(tag) = self.line_docs.get(&(entry, idx)).copied() {
|
||
self.tag_line(id, idx, tag);
|
||
}
|
||
}
|
||
self.entries
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownEntry(id))?
|
||
.mark_reversal_of(entry);
|
||
self.post_entry(id)?;
|
||
// A reversed payment un-pays the vendor: carry the tags.
|
||
if let Some(v) = self.entry_vendor(entry) {
|
||
self.entry_vendors.insert(id, v);
|
||
}
|
||
// Paid the same way: its 1099 buckets net to zero too.
|
||
if let Some(i) = self.entry_instrument(entry) {
|
||
self.entry_instruments.insert(id, i);
|
||
}
|
||
// Every party and share: the reversal undoes each one's part.
|
||
if let Some(p) = self.entry_parties.get(&entry).cloned() {
|
||
self.entry_parties.insert(id, p);
|
||
}
|
||
// An entry with no counterparty is reversed with none either
|
||
// (set_entry_no_party INVARIANT 1).
|
||
if let Some(why) = self.entry_no_party.get(&entry).cloned() {
|
||
self.entry_no_party.insert(id, why);
|
||
}
|
||
// A reversed schedule month is un-posted, so it is due again; a
|
||
// reversed reversal posts it back (P3 depreciation review R3).
|
||
if let Some(top) = self.schedule_chain_top(entry)
|
||
&& let Some((sid, month)) = self.schedule_month_of(top).map(|(s, m)| (s.id, m))
|
||
{
|
||
let posted = self.schedules[&sid].postings[&month]
|
||
.iter()
|
||
.any(|&e| self.in_effect(e));
|
||
let s = self.schedules.get_mut(&sid).expect("found");
|
||
if posted {
|
||
s.posted.insert(month);
|
||
} else {
|
||
s.posted.remove(&month);
|
||
}
|
||
}
|
||
self.refresh_docs_of(id);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Posted entries in posting order: the append-only log.
|
||
pub fn posted_entries(&self) -> impl Iterator<Item = &JournalEntry> + '_ {
|
||
self.order.iter().filter_map(|id| self.entries.get(id))
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Fixed assets
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Pure pre-validation for [`Self::register_asset`]: every check that
|
||
/// does not mutate, so journaling stores can validate before journaling.
|
||
/// [`Self::validate_new_asset_journaled`]; the in-service date and the
|
||
/// life's last day of depreciation inside the calendar the books keep
|
||
/// (crate::calendar): a life whose last day is after 9999-12-31 is
|
||
/// refused like one past chrono's last day (P0 review round 3, S1; P3:
|
||
/// the bound is the last day held, so a life ending on 9999-12-31 is
|
||
/// taken); and the cost and salvage in the currency's minor unit
|
||
/// (rules.md B-3: every depreciation and disposal line derives from
|
||
/// them, and a line finer than a cent could never post).
|
||
pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> {
|
||
self.check_new_asset(new, true)
|
||
}
|
||
|
||
/// The checks every journaled `asset.registered` and `asset.corrected`
|
||
/// passed when it was written (the rule through 2026-10-16):
|
||
/// [`Self::validate_new_asset`] without the calendar bound or the minor
|
||
/// unit, the end of the life only representable. Replay applies
|
||
/// exactly these (rules.md B-28).
|
||
pub fn validate_new_asset_journaled(&self, new: &NewAsset) -> Result<(), LedgerError> {
|
||
self.check_new_asset(new, false)
|
||
}
|
||
|
||
/// The checks of [`Self::validate_new_asset`] (`strict`) or
|
||
/// [`Self::validate_new_asset_journaled`], in one order.
|
||
fn check_new_asset(&self, new: &NewAsset, strict: bool) -> Result<(), LedgerError> {
|
||
if new.description.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAssetDescription);
|
||
}
|
||
if new.cost.amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
|
||
}
|
||
// Every depreciation and disposal line derives from the cost: one
|
||
// that no line could carry is refused here, not months later.
|
||
if new.cost.amount > kernel::MAX_LINE_AMOUNT {
|
||
return Err(LedgerError::AmountOutOfRange);
|
||
}
|
||
if new.cost.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: new.cost.currency.code().to_string(),
|
||
});
|
||
}
|
||
if new.salvage_value.amount < Decimal::ZERO || new.salvage_value.amount >= new.cost.amount {
|
||
return Err(LedgerError::InvalidSalvageValue(new.salvage_value.amount));
|
||
}
|
||
if new.salvage_value.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: new.salvage_value.currency.code().to_string(),
|
||
});
|
||
}
|
||
if new.useful_life_months < 1 {
|
||
return Err(LedgerError::InvalidUsefulLife(new.useful_life_months));
|
||
}
|
||
if strict {
|
||
check_minor_units(new.cost)?;
|
||
check_minor_units(new.salvage_value)?;
|
||
}
|
||
// The schedule walks every month of the asset's life; a purchase
|
||
// date near the end of the calendar would overflow date arithmetic
|
||
// (and hang or panic the schedule). Reject it up front. New ones
|
||
// keep the in-service date and the life's last day (the day before
|
||
// its exclusive end) inside the calendar the books keep, so every
|
||
// month of the life is a month the books can post.
|
||
if strict {
|
||
crate::calendar::check(new.in_service_date)?;
|
||
}
|
||
let end = new
|
||
.in_service_date
|
||
.checked_add_months(Months::new(new.useful_life_months));
|
||
if !end
|
||
.is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar))
|
||
{
|
||
return Err(LedgerError::AssetDateOverflow);
|
||
}
|
||
assets::validate_asset_accounts(
|
||
&self.chart,
|
||
new.cost_account,
|
||
new.accum_depr_account,
|
||
new.expense_account,
|
||
)
|
||
}
|
||
|
||
/// Register a fixed asset. Validates the economics (positive cost,
|
||
/// salvage below cost, at least one month of life, base currency) and
|
||
/// the account links (asset/asset/expense types, three distinct
|
||
/// accounts).
|
||
///
|
||
/// ```text
|
||
/// GOAL: put a fixed asset on the register, to depreciate monthly.
|
||
/// SOURCE: docs/spec/rules.md B-15; B-22 (capitalize vs expense).
|
||
/// SCENARIOS:
|
||
/// 1. IF the description is blank, cost isn't positive, salvage is
|
||
/// negative or not below cost, the useful life is zero months, or the
|
||
/// accounts aren't the right types: SHOULD refuse.
|
||
/// ```
|
||
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, LedgerError> {
|
||
self.validate_new_asset(&new)?;
|
||
self.insert_asset(new)
|
||
}
|
||
|
||
/// [`Self::register_asset`] with the checks every journaled
|
||
/// `asset.registered` passed when it was written
|
||
/// ([`Self::validate_new_asset_journaled`]), for replay only (rules.md
|
||
/// B-28). Never for new work.
|
||
pub fn replay_register_asset(&mut self, new: NewAsset) -> Result<AssetId, LedgerError> {
|
||
self.validate_new_asset_journaled(&new)?;
|
||
self.insert_asset(new)
|
||
}
|
||
|
||
/// Put a checked asset on the register under the next id.
|
||
fn insert_asset(&mut self, new: NewAsset) -> Result<AssetId, LedgerError> {
|
||
let id = self.next_asset_id;
|
||
self.next_asset_id += 1;
|
||
self.assets.insert(
|
||
id,
|
||
Asset {
|
||
id,
|
||
description: new.description,
|
||
in_service_date: new.in_service_date,
|
||
cost: new.cost,
|
||
salvage_value: new.salvage_value,
|
||
useful_life_months: new.useful_life_months,
|
||
method: crate::DepreciationMethod::StraightLine,
|
||
cost_account: new.cost_account,
|
||
accum_depr_account: new.accum_depr_account,
|
||
expense_account: new.expense_account,
|
||
status: AssetStatus::Active,
|
||
tag: None,
|
||
serial: None,
|
||
acquired_by: None,
|
||
basis: None,
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Look up a fixed asset.
|
||
pub fn get_asset(&self, id: AssetId) -> Option<&Asset> {
|
||
self.assets.get(&id)
|
||
}
|
||
|
||
/// All registered assets, in registration order.
|
||
pub fn registered_assets(&self) -> impl Iterator<Item = &Asset> + '_ {
|
||
let mut ids: Vec<AssetId> = self.assets.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter().filter_map(|id| self.assets.get(&id))
|
||
}
|
||
|
||
/// Give an asset a tag (or a new one): the label on the thing itself.
|
||
///
|
||
/// ```text
|
||
/// GOAL: find an asset in the books from the label on it, and the
|
||
/// label from the books.
|
||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||
/// INVARIANTS:
|
||
/// 1. Tags are unique among all assets, ignoring case.
|
||
/// 2. A tag is never reused by another asset, even after the asset
|
||
/// that held it was retagged or disposed: an old label never
|
||
/// points at the wrong thing. The same asset may take back a tag
|
||
/// it held.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset is unknown: SHOULD refuse.
|
||
/// 2. IF the tag's form is wrong (assets::check_asset_tag): SHOULD
|
||
/// refuse with InvalidAssetTag.
|
||
/// 3. IF another asset holds or held the tag: SHOULD refuse with
|
||
/// AssetTagTaken.
|
||
/// 4. IF the asset already has that tag: SHOULD accept (no change).
|
||
/// ```
|
||
pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), LedgerError> {
|
||
self.check_asset_tag_for(Some(id), tag)?;
|
||
self.asset_tags.insert(assets::tag_key(tag), id);
|
||
self.assets.get_mut(&id).expect("checked").tag = Some(tag.to_string());
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether `tag` can go on asset `id` (`None`: on an asset not yet
|
||
/// registered), for stores that journal before mutating.
|
||
pub fn check_asset_tag_for(&self, id: Option<AssetId>, tag: &str) -> Result<(), LedgerError> {
|
||
if let Some(id) = id
|
||
&& !self.assets.contains_key(&id)
|
||
{
|
||
return Err(LedgerError::UnknownAsset(id));
|
||
}
|
||
assets::check_asset_tag(tag).map_err(|why| LedgerError::InvalidAssetTag {
|
||
tag: tag.to_string(),
|
||
why,
|
||
})?;
|
||
match self.asset_tags.get(&assets::tag_key(tag)) {
|
||
Some(&holder) if Some(holder) != id => Err(LedgerError::AssetTagTaken {
|
||
tag: tag.to_string(),
|
||
asset: holder,
|
||
}),
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// Record (or, with `None`, clear) an asset's serial number.
|
||
///
|
||
/// ```text
|
||
/// GOAL: keep the manufacturer's serial with the asset, for warranty,
|
||
/// insurance, and proving which thing the books mean.
|
||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset is unknown, or the serial's form is wrong
|
||
/// (assets::check_serial): SHOULD refuse, changing nothing.
|
||
/// ```
|
||
pub fn set_asset_serial(
|
||
&mut self,
|
||
id: AssetId,
|
||
serial: Option<&str>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_asset_serial(id, serial)?;
|
||
self.assets.get_mut(&id).expect("checked").serial = serial.map(str::to_string);
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_asset_serial`].
|
||
pub fn check_asset_serial(&self, id: AssetId, serial: Option<&str>) -> Result<(), LedgerError> {
|
||
if !self.assets.contains_key(&id) {
|
||
return Err(LedgerError::UnknownAsset(id));
|
||
}
|
||
if let Some(s) = serial {
|
||
assets::check_serial(s).map_err(LedgerError::InvalidSerial)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Link an asset to the posted entry that put it on the books: its
|
||
/// purchase, or a member's contribution of it. The entry's date is the
|
||
/// asset's acquired date ([`Self::asset_acquired_date`]).
|
||
///
|
||
/// ```text
|
||
/// GOAL: from an asset, its source document and capital entry; from
|
||
/// the entry, the asset; and the day the company acquired it.
|
||
/// SOURCE: DECISION (owner, 2026-10-15); rules.md B-15 (P3, settled in
|
||
/// design 2026-10-20: an asset's acquired date comes from the
|
||
/// entry it is linked to).
|
||
/// INVARIANTS:
|
||
/// 1. The linked entry is posted, is not a reversal, has not been
|
||
/// reversed, and debits the asset's cost account.
|
||
/// 2. The linked entry is dated on or before the asset's in-service
|
||
/// date, and on or before its disposal: the register then holds
|
||
/// the asset exactly while the books carry its cost (B-24).
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset or entry is unknown, or the entry fails 1 or 2:
|
||
/// SHOULD refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
||
/// UnknownEntry).
|
||
/// 2. IF a later correction reverses the linked entry: link the
|
||
/// replacement (a reversed link stays as history in the journal).
|
||
/// ```
|
||
pub fn set_asset_acquisition(
|
||
&mut self,
|
||
id: AssetId,
|
||
entry: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_asset_acquisition(id, entry)?;
|
||
self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry);
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::set_asset_acquisition`] with the checks every journaled
|
||
/// `asset.acquisition_linked` passed when it was written (INVARIANT 1,
|
||
/// without the dates of INVARIANT 2), for replay only (rules.md B-28).
|
||
pub fn replay_set_asset_acquisition(
|
||
&mut self,
|
||
id: AssetId,
|
||
entry: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_asset_acquisition_journaled(id, entry)?;
|
||
self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry);
|
||
Ok(())
|
||
}
|
||
|
||
/// Restore a link recorded earlier (a projection read-back): it was
|
||
/// checked when made, and a later correction (reversing that entry)
|
||
/// doesn't undo it; it only becomes a link to fix, by linking the
|
||
/// replacement. Checks only that the asset and entry exist. (Re-checking
|
||
/// against today's books refused to open them once a linked entry was
|
||
/// reversed, 2026-10-15.)
|
||
pub fn restore_asset_acquisition(
|
||
&mut self,
|
||
id: AssetId,
|
||
entry: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
self.assets
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownAsset(id))?
|
||
.acquired_by = Some(entry);
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether an asset's acquisition link points at an entry that has
|
||
/// since been reversed (to relink the replacement).
|
||
pub fn acquisition_reversed(&self, id: AssetId) -> bool {
|
||
self.assets
|
||
.get(&id)
|
||
.and_then(|a| a.acquired_by)
|
||
.is_some_and(|e| self.entries.values().any(|x| x.reversal_of() == Some(e)))
|
||
}
|
||
|
||
/// The day the company acquired an asset: its acquisition entry's date,
|
||
/// or its in-service date when no entry is linked (every asset
|
||
/// registered before P3 without a link), never later than the
|
||
/// in-service date. `None` for an unknown asset.
|
||
///
|
||
/// ```text
|
||
/// GOAL: the register holds an asset from the day the books carry its
|
||
/// cost, which can be before it serves (B-24).
|
||
/// SOURCE: rules.md B-15, B-24; P3, settled in design (2026-10-20):
|
||
/// "an asset's acquired date comes from the entry it is linked
|
||
/// to".
|
||
/// INVARIANTS:
|
||
/// 1. acquired = min(in_service_date, the linked entry's date); the
|
||
/// in-service date with no link. One source of truth: no stored
|
||
/// date to disagree with the entry.
|
||
/// ```
|
||
pub fn asset_acquired_date(&self, id: AssetId) -> Option<NaiveDate> {
|
||
let a = self.assets.get(&id)?;
|
||
Some(
|
||
a.acquired_by
|
||
.and_then(|e| self.entries.get(&e))
|
||
.map_or(a.in_service_date, |e| e.date().min(a.in_service_date)),
|
||
)
|
||
}
|
||
|
||
/// The validation behind [`Self::set_asset_acquisition`]: INVARIANTS 1
|
||
/// and 2.
|
||
pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> {
|
||
self.check_asset_acquisition_journaled(id, entry)?;
|
||
let asset = self.assets.get(&id).expect("checked");
|
||
let date = self.entries.get(&entry).expect("checked").date();
|
||
let why = if date > asset.in_service_date {
|
||
Some(format!(
|
||
"it is dated {date}, after the asset's in-service date {} (an asset is acquired before it serves: correct the in-service date with books.correct_asset, or the entry)",
|
||
asset.in_service_date
|
||
))
|
||
} else {
|
||
match &asset.status {
|
||
AssetStatus::Disposed { date: gone, .. } if date > *gone => Some(format!(
|
||
"it is dated {date}, after the asset's disposal on {gone}"
|
||
)),
|
||
_ => None,
|
||
}
|
||
};
|
||
match why {
|
||
Some(why) => Err(LedgerError::AcquisitionEntryMismatch {
|
||
asset: id,
|
||
entry,
|
||
why,
|
||
}),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// INVARIANT 1 of [`Self::set_asset_acquisition`]: the checks every
|
||
/// journaled `asset.acquisition_linked` passed.
|
||
fn check_asset_acquisition_journaled(
|
||
&self,
|
||
id: AssetId,
|
||
entry: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||
let e = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
let why = if e.status() != crate::EntryStatus::Posted {
|
||
Some("it isn't posted")
|
||
} else if e.reversal_of().is_some() {
|
||
Some("it is a reversal")
|
||
} else if self
|
||
.entries
|
||
.values()
|
||
.any(|x| x.reversal_of() == Some(entry))
|
||
{
|
||
Some("it has been reversed")
|
||
} else if !e
|
||
.lines()
|
||
.iter()
|
||
.any(|l| l.account == asset.cost_account && l.side == Side::Debit)
|
||
{
|
||
Some("it doesn't debit the asset's cost account")
|
||
} else {
|
||
None
|
||
};
|
||
match why {
|
||
Some(why) => Err(LedgerError::AcquisitionEntryMismatch {
|
||
asset: id,
|
||
entry,
|
||
why: why.to_string(),
|
||
}),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// Change an asset's description (what it is, as the register and its
|
||
/// label print it). Its accounting doesn't change.
|
||
///
|
||
/// ```text
|
||
/// GOAL: keep the register's words right when they go stale (a
|
||
/// corrected entry, a better name).
|
||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset is unknown, or the description is blank: SHOULD
|
||
/// refuse, changing nothing.
|
||
/// ```
|
||
pub fn set_asset_description(
|
||
&mut self,
|
||
id: AssetId,
|
||
description: &str,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_asset_description(id, description)?;
|
||
self.assets.get_mut(&id).expect("checked").description = description.to_string();
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_asset_description`].
|
||
pub fn check_asset_description(
|
||
&self,
|
||
id: AssetId,
|
||
description: &str,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.assets.contains_key(&id) {
|
||
return Err(LedgerError::UnknownAsset(id));
|
||
}
|
||
if description.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAssetDescription);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Record how the company got an asset and the facts its tax figures
|
||
/// follow from ([`assets::AssetBasis`]): the rule, and for a member's
|
||
/// contribution the member's adjusted basis, the fair market value at
|
||
/// contribution, the §704(c) method if chosen, and the source. Book
|
||
/// figures don't change.
|
||
///
|
||
/// ```text
|
||
/// GOAL: the register carries the tax figures beside book value, each
|
||
/// named for what it is, so book and tax never get confused and
|
||
/// the CPA sees both.
|
||
/// SOURCE: docs/spec/rules.md B-25; docs/spec/sources/
|
||
/// irs-partnership-basis.md; DECISION D-41 (owner, 2026-10-20).
|
||
/// INVARIANTS:
|
||
/// 1. The record passes AssetBasis::check: a purchase takes no
|
||
/// contribution facts; a contribution needs the adjusted basis and
|
||
/// the value at contribution, each non-negative, in cents, at
|
||
/// most one quadrillion.
|
||
/// 2. Only facts are kept: the figures are derived when read
|
||
/// (Asset::basis_figures), under the rule in force.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset is unknown, or 1 fails: SHOULD refuse with
|
||
/// UnknownAsset or InvalidTaxBasis.
|
||
/// 2. IF set again: the new record replaces the old (the journal
|
||
/// keeps both).
|
||
/// ```
|
||
pub fn set_asset_basis(
|
||
&mut self,
|
||
id: AssetId,
|
||
basis: assets::AssetBasis,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_asset_basis(id, &basis)?;
|
||
self.assets.get_mut(&id).expect("checked").basis = Some(basis);
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_asset_basis`].
|
||
pub fn check_asset_basis(
|
||
&self,
|
||
id: AssetId,
|
||
basis: &assets::AssetBasis,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.assets.contains_key(&id) {
|
||
return Err(LedgerError::UnknownAsset(id));
|
||
}
|
||
basis.check().map_err(LedgerError::InvalidTaxBasis)
|
||
}
|
||
|
||
/// [`Self::set_asset_basis`] with the checks every journaled
|
||
/// `asset.basis_set` passed (AssetBasis::check_journaled: the facts fit
|
||
/// the rule, none negative, no bound on size), for replay and the
|
||
/// projection's read-back only (rules.md B-28). The figures the line
|
||
/// carries are for its readers: replay never recomputes them, so a
|
||
/// later change to how a figure is derived never refuses books an
|
||
/// earlier version wrote. Never for new work.
|
||
pub fn replay_set_asset_basis(
|
||
&mut self,
|
||
id: AssetId,
|
||
basis: assets::AssetBasis,
|
||
) -> Result<(), LedgerError> {
|
||
let asset = self
|
||
.assets
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownAsset(id))?;
|
||
basis
|
||
.check_journaled()
|
||
.map_err(LedgerError::InvalidTaxBasis)?;
|
||
asset.basis = Some(basis);
|
||
Ok(())
|
||
}
|
||
|
||
/// Replay a pre-P3 `asset.tax_basis_set` (rule and contributor basis,
|
||
/// no value at contribution, which reads as the book cost): the checks
|
||
/// it passed when written (AssetBasis::check_legacy_journaled), for
|
||
/// replay and the projection's read-back only (rules.md B-28). Never for
|
||
/// new work.
|
||
pub fn replay_set_asset_tax_basis(
|
||
&mut self,
|
||
id: AssetId,
|
||
rule: assets::BasisRule,
|
||
contributor_basis: Option<Decimal>,
|
||
) -> Result<(), LedgerError> {
|
||
let asset = self
|
||
.assets
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownAsset(id))?;
|
||
let basis = assets::AssetBasis {
|
||
rule,
|
||
contributor_adjusted_basis: contributor_basis,
|
||
fmv_at_contribution: None,
|
||
sec704c_method: None,
|
||
source: None,
|
||
};
|
||
basis
|
||
.check_legacy_journaled()
|
||
.map_err(LedgerError::InvalidTaxBasis)?;
|
||
asset.basis = Some(basis);
|
||
Ok(())
|
||
}
|
||
|
||
/// Correct a registered asset's economics (cost, salvage, useful life,
|
||
/// in-service date) when it was registered wrong. Its accounts, tag,
|
||
/// serial, links, and basis record stay.
|
||
///
|
||
/// ```text
|
||
/// GOAL: fix a registration mistake without re-registering (which would
|
||
/// burn its tag, since tags are never reused).
|
||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||
/// INVARIANTS:
|
||
/// 1. The corrected figures pass every check a new registration does
|
||
/// (validate_new_asset).
|
||
/// 2. Only an active asset is corrected; a store also refuses once any
|
||
/// depreciation for it is posted (the posted entries would disagree
|
||
/// with the schedule; correct with entries instead).
|
||
/// 3. The in-service date is never before the linked acquisition
|
||
/// entry's date (set_asset_acquisition INVARIANT 2).
|
||
/// 4. A contribution's value at contribution and built-in gain stay
|
||
/// as recorded: they were fixed when it was contributed (Treas.
|
||
/// Reg. §1.704-3(a)(3)(ii)), whatever the book cost becomes.
|
||
/// 5. A refusal MUST leave the books unchanged.
|
||
/// ```
|
||
pub fn correct_asset(
|
||
&mut self,
|
||
id: AssetId,
|
||
fix: assets::AssetCorrection,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_correct_asset(id, &fix)?;
|
||
self.apply_asset_correction(id, fix);
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::correct_asset`] with the checks every journaled
|
||
/// `asset.corrected` passed when it was written
|
||
/// ([`Self::validate_new_asset_journaled`]), for replay only (rules.md
|
||
/// B-28). Never for new work.
|
||
pub fn replay_correct_asset(
|
||
&mut self,
|
||
id: AssetId,
|
||
fix: assets::AssetCorrection,
|
||
) -> Result<(), LedgerError> {
|
||
let corrected = self.corrected_asset(id, &fix)?;
|
||
self.validate_new_asset_journaled(&corrected)?;
|
||
self.apply_asset_correction(id, fix);
|
||
Ok(())
|
||
}
|
||
|
||
/// Write checked corrected figures onto the asset.
|
||
fn apply_asset_correction(&mut self, id: AssetId, fix: assets::AssetCorrection) {
|
||
let a = self.assets.get_mut(&id).expect("checked");
|
||
a.cost = fix.cost;
|
||
a.salvage_value = fix.salvage_value;
|
||
a.useful_life_months = fix.useful_life_months;
|
||
a.in_service_date = fix.in_service_date;
|
||
}
|
||
|
||
/// The validation behind [`Self::correct_asset`] (INVARIANTS 1-3).
|
||
pub fn check_correct_asset(
|
||
&self,
|
||
id: AssetId,
|
||
fix: &assets::AssetCorrection,
|
||
) -> Result<(), LedgerError> {
|
||
self.validate_new_asset(&self.corrected_asset(id, fix)?)?;
|
||
if let Some(linked) = self
|
||
.assets
|
||
.get(&id)
|
||
.and_then(|a| a.acquired_by)
|
||
.and_then(|e| self.entries.get(&e))
|
||
.map(|e| e.date())
|
||
&& fix.in_service_date < linked
|
||
{
|
||
return Err(LedgerError::InServiceBeforeAcquisition {
|
||
asset: id,
|
||
in_service: fix.in_service_date.to_string(),
|
||
acquired: linked.to_string(),
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The active asset `id` as `fix` would leave it, as a registration to
|
||
/// check.
|
||
fn corrected_asset(
|
||
&self,
|
||
id: AssetId,
|
||
fix: &assets::AssetCorrection,
|
||
) -> Result<NewAsset, LedgerError> {
|
||
let a = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||
if a.status != AssetStatus::Active {
|
||
return Err(LedgerError::AssetAlreadyDisposed(id));
|
||
}
|
||
Ok(NewAsset {
|
||
description: a.description.clone(),
|
||
in_service_date: fix.in_service_date,
|
||
cost: fix.cost,
|
||
salvage_value: fix.salvage_value,
|
||
useful_life_months: fix.useful_life_months,
|
||
cost_account: a.cost_account,
|
||
accum_depr_account: a.accum_depr_account,
|
||
expense_account: a.expense_account,
|
||
})
|
||
}
|
||
|
||
/// The asset whose current tag is `tag`, ignoring case.
|
||
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
|
||
let id = self.asset_tags.get(&assets::tag_key(tag))?;
|
||
self.assets
|
||
.get(id)
|
||
.filter(|a| a.tag.as_deref().map(assets::tag_key) == Some(assets::tag_key(tag)))
|
||
}
|
||
|
||
/// The first free generated tag, `PREFIX-0001`, `PREFIX-0002`, ...:
|
||
/// the lowest number no asset holds or held under that prefix.
|
||
/// Numbers past 9999 widen.
|
||
pub fn next_asset_tag(&self, prefix: &str) -> String {
|
||
(1u64..)
|
||
.map(|n| format!("{prefix}-{n:04}"))
|
||
.find(|t| !self.asset_tags.contains_key(&assets::tag_key(t)))
|
||
.expect("some number is free")
|
||
}
|
||
|
||
/// Whether a depreciation run can be recorded, for a run a store
|
||
/// journals now: the asset is known; the period is a month, "YYYY-MM";
|
||
/// the amount is positive and in cents; and the entry is posted and
|
||
/// credits the asset's accumulated depreciation account by exactly the
|
||
/// amount.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a run the books record is the entry that posted it, no more
|
||
/// and no less, so accumulated_posted is what the books carry.
|
||
/// SOURCE: rules.md B-15 rules 6 and 8, B-24; B-28 (new commands are bounded; replay checks
|
||
/// only what every journaled run passed:
|
||
/// record_depreciation_run).
|
||
/// ```
|
||
pub fn check_depreciation_run(
|
||
&self,
|
||
asset: AssetId,
|
||
period: &str,
|
||
entry: EntryId,
|
||
amount: Decimal,
|
||
) -> Result<(), LedgerError> {
|
||
let a = self
|
||
.assets
|
||
.get(&asset)
|
||
.ok_or(LedgerError::UnknownAsset(asset))?;
|
||
let bad = |why: String| LedgerError::InvalidDepreciationRun { asset, why };
|
||
let month_ok = period.len() == 7
|
||
&& period.as_bytes()[4] == b'-'
|
||
&& NaiveDate::parse_from_str(&format!("{period}-01"), "%Y-%m-%d").is_ok();
|
||
if !month_ok {
|
||
return Err(bad(format!("period {period:?} isn't a month (YYYY-MM)")));
|
||
}
|
||
if amount <= Decimal::ZERO || amount.normalize().scale() > 2 {
|
||
return Err(bad(format!(
|
||
"amount {amount} isn't a positive number of cents"
|
||
)));
|
||
}
|
||
let e = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
if e.status() != crate::EntryStatus::Posted {
|
||
return Err(bad(format!("entry {entry} isn't posted")));
|
||
}
|
||
let credited = kernel::net(
|
||
e.lines()
|
||
.iter()
|
||
.filter(|l| l.account == a.accum_depr_account)
|
||
.map(|l| (kernel::flip(l.side), l.amount.amount)),
|
||
)
|
||
.ok_or_else(|| bad("its entry's lines are too large".into()))?;
|
||
if credited != amount {
|
||
return Err(bad(format!(
|
||
"entry {entry} credits accumulated depreciation {credited}, not {amount}"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Record a depreciation run (`asset.depreciation_posted`). Checks only
|
||
/// that the asset and the entry exist: every run an earlier version
|
||
/// journaled replays, whatever its period or amount (rules.md B-28; a
|
||
/// store checks new runs first, [`Self::check_depreciation_run`]). A
|
||
/// second line for the same asset, period and entry (a journal edited
|
||
/// by hand; a store never writes one) is the run already recorded: one
|
||
/// entry is carried once, as the projection keys it (P3 review,
|
||
/// 2026-10-21: replay counted it twice, the reload once).
|
||
pub fn record_depreciation_run(
|
||
&mut self,
|
||
asset: AssetId,
|
||
period: &str,
|
||
entry: EntryId,
|
||
amount: Decimal,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.assets.contains_key(&asset) {
|
||
return Err(LedgerError::UnknownAsset(asset));
|
||
}
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
let runs = self.asset_runs.entry(asset).or_default();
|
||
if runs.iter().any(|r| r.entry == entry && r.period == period) {
|
||
return Ok(());
|
||
}
|
||
runs.push(assets::DepreciationPosting {
|
||
period: period.to_string(),
|
||
entry,
|
||
amount,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
/// An asset's depreciation runs, in the order recorded.
|
||
pub fn depreciation_runs(&self, asset: AssetId) -> &[assets::DepreciationPosting] {
|
||
self.asset_runs.get(&asset).map_or(&[], Vec::as_slice)
|
||
}
|
||
|
||
/// Each posted reversal's original: original entry → its reversal (an
|
||
/// entry is reversed at most once, B-10). Read once for many runs
|
||
/// ([`assets::depreciation_effects`], [`Self::accumulated_posted_all`]),
|
||
/// never per run: the viewer asks it of every asset.
|
||
pub(crate) fn posted_reversals(&self) -> HashMap<EntryId, EntryId> {
|
||
self.posted_entries()
|
||
.filter_map(|e| e.reversal_of().map(|orig| (orig, e.id())))
|
||
.collect()
|
||
}
|
||
|
||
/// An asset's runs as the catch-up rule reads them: (entry, amount),
|
||
/// in the order recorded ([`assets::depreciation_effects`]).
|
||
pub fn depreciation_run_entries(&self, asset: AssetId) -> Vec<(EntryId, Decimal)> {
|
||
self.depreciation_runs(asset)
|
||
.iter()
|
||
.map(|r| (r.entry, r.amount))
|
||
.collect()
|
||
}
|
||
|
||
/// What the books carry for an asset's depreciation, entry by entry,
|
||
/// each in its own month: every run and every reversal of one
|
||
/// ([`assets::depreciation_effects`] of its runs).
|
||
pub fn depreciation_effects_of(&self, asset: AssetId) -> Vec<assets::PostedRun> {
|
||
assets::depreciation_effects(self, &self.depreciation_run_entries(asset))
|
||
}
|
||
|
||
/// Accumulated depreciation the books carry for an asset on `as_of`:
|
||
/// what its runs carry through that day, entry by entry
|
||
/// ([`assets::depreciation_effects`]' reading, by day); zero once it
|
||
/// is disposed (the disposal entry cleared it).
|
||
///
|
||
/// ```text
|
||
/// GOAL: book value is cost less what the books carry, never the
|
||
/// schedule's figure.
|
||
/// SOURCE: rules.md B-24 (the register reconciles to the general
|
||
/// ledger); B-8, B-9, B-10 (a reversal undoes what it reverses,
|
||
/// and reversing the reversal reinstates it); B-15 (what the
|
||
/// catch-up rule reads, the same entries).
|
||
/// INVARIANTS:
|
||
/// 1. Disposed on or before as_of: 0.
|
||
/// 2. Otherwise Σ, over the asset's runs and every entry of each
|
||
/// one's reversal chain the books carry (assets::run_chain_entries:
|
||
/// the run +amount, its reversal −amount, that reversal's
|
||
/// reversal +amount, ...), of those dated on or before as_of. So
|
||
/// it moves exactly as the trial balance does, even for a
|
||
/// reversal dated before its run, and agrees with
|
||
/// depreciation_effects summed through any month's end.
|
||
/// 3. NEVER panics: a sum past exact arithmetic is refused
|
||
/// (TooLargeToCompute).
|
||
/// ```
|
||
pub fn accumulated_posted(
|
||
&self,
|
||
id: AssetId,
|
||
as_of: NaiveDate,
|
||
) -> Result<Decimal, LedgerError> {
|
||
self.accumulated_posted_with(id, as_of, &self.posted_reversals())
|
||
}
|
||
|
||
/// [`Self::accumulated_posted`] for every registered asset, the
|
||
/// reversal links read once (for the register and the viewer).
|
||
pub fn accumulated_posted_all(
|
||
&self,
|
||
as_of: NaiveDate,
|
||
) -> Result<std::collections::BTreeMap<AssetId, Decimal>, LedgerError> {
|
||
let reversals = self.posted_reversals();
|
||
self.assets
|
||
.keys()
|
||
.map(|&id| Ok((id, self.accumulated_posted_with(id, as_of, &reversals)?)))
|
||
.collect()
|
||
}
|
||
|
||
fn accumulated_posted_with(
|
||
&self,
|
||
id: AssetId,
|
||
as_of: NaiveDate,
|
||
reversals: &HashMap<EntryId, EntryId>,
|
||
) -> Result<Decimal, LedgerError> {
|
||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||
if matches!(asset.status, AssetStatus::Disposed { date, .. } if date <= as_of) {
|
||
return Ok(Decimal::ZERO);
|
||
}
|
||
self.carried_between(id, NaiveDate::MIN, as_of, reversals)
|
||
}
|
||
|
||
/// Depreciation the books posted for an asset through entries dated in
|
||
/// `[from, to]` (its runs and their reversals, as
|
||
/// [`Self::accumulated_posted`] counts them, the disposal aside): the
|
||
/// period's depreciation expense from its runs.
|
||
pub fn depreciation_posted_between(
|
||
&self,
|
||
id: AssetId,
|
||
from: NaiveDate,
|
||
to: NaiveDate,
|
||
) -> Result<Decimal, LedgerError> {
|
||
if !self.assets.contains_key(&id) {
|
||
return Err(LedgerError::UnknownAsset(id));
|
||
}
|
||
self.carried_between(id, from, to, &self.posted_reversals())
|
||
}
|
||
|
||
/// What the entries of the asset's runs' chains dated in `[from, to]`
|
||
/// carry, summed exactly.
|
||
fn carried_between(
|
||
&self,
|
||
id: AssetId,
|
||
from: NaiveDate,
|
||
to: NaiveDate,
|
||
reversals: &HashMap<EntryId, EntryId>,
|
||
) -> Result<Decimal, LedgerError> {
|
||
let too_large =
|
||
|| LedgerError::TooLargeToCompute(format!("asset {id}'s posted depreciation"));
|
||
assets::run_chain_entries(self, &self.depreciation_run_entries(id), reversals)
|
||
.into_iter()
|
||
.filter(|(d, _)| *d >= from && *d <= to)
|
||
.try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(a))
|
||
.ok_or_else(too_large)
|
||
}
|
||
|
||
/// The run the books carry for an asset's month, if any: the first one
|
||
/// recorded for `period` whose entry is in effect ([`Self::in_effect`]:
|
||
/// posted, and an even number of posted reversals after it). A month
|
||
/// whose run was reversed has none, so it can be run again; one whose
|
||
/// reversal was itself reversed has its run back. At most one run of a
|
||
/// month is in effect: a store refuses re-instating a run whose month
|
||
/// was run again.
|
||
pub fn live_depreciation_run(
|
||
&self,
|
||
id: AssetId,
|
||
period: &str,
|
||
) -> Option<&assets::DepreciationPosting> {
|
||
self.depreciation_runs(id)
|
||
.iter()
|
||
.find(|run| run.period == period && self.in_effect(run.entry))
|
||
}
|
||
|
||
/// Mark an asset disposed. The disposal *entry* is posted through the
|
||
/// normal machinery by the caller; this records the lifecycle change.
|
||
///
|
||
/// ```text
|
||
/// GOAL: take an asset off the books when sold or scrapped.
|
||
/// SOURCE: docs/spec/rules.md B-15; CONVENTION (gain or loss on disposal).
|
||
/// INVARIANTS:
|
||
/// 1. The gain (or loss) MUST equal proceeds minus book value at the
|
||
/// disposal date.
|
||
/// 2. The asset's cost and accumulated depreciation MUST both leave the
|
||
/// books.
|
||
/// SCENARIOS:
|
||
/// 1. IF the asset is unknown or already disposed: SHOULD refuse.
|
||
/// ```
|
||
pub fn dispose_asset(
|
||
&mut self,
|
||
id: AssetId,
|
||
date: NaiveDate,
|
||
proceeds: Money,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_dispose_asset(id, date, proceeds, true)?;
|
||
self.assets.get_mut(&id).expect("asset exists").status =
|
||
AssetStatus::Disposed { date, proceeds };
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::dispose_asset`] with the checks every journaled
|
||
/// `asset.disposed` passed when it was written (no bound on the
|
||
/// proceeds or the date beyond the disposal's own), for replay only
|
||
/// (rules.md B-28). Never for new work.
|
||
pub fn replay_dispose_asset(
|
||
&mut self,
|
||
id: AssetId,
|
||
date: NaiveDate,
|
||
proceeds: Money,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_dispose_asset(id, date, proceeds, false)?;
|
||
self.assets.get_mut(&id).expect("asset exists").status =
|
||
AssetStatus::Disposed { date, proceeds };
|
||
Ok(())
|
||
}
|
||
|
||
/// The checks of [`Self::dispose_asset`] (`strict`) or
|
||
/// [`Self::replay_dispose_asset`].
|
||
fn check_dispose_asset(
|
||
&self,
|
||
id: AssetId,
|
||
date: NaiveDate,
|
||
proceeds: Money,
|
||
strict: bool,
|
||
) -> Result<(), LedgerError> {
|
||
if proceeds.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: proceeds.currency.code().to_string(),
|
||
});
|
||
}
|
||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||
// Activeness and the proceeds' sign; no depreciation is computed,
|
||
// so a journaled disposal replays even where the figure can't be
|
||
// (rules.md B-28).
|
||
assets::check_disposal(asset, proceeds)?;
|
||
if strict {
|
||
// Never before the company acquired it. A command's bound, not
|
||
// replay's: the acquired date needs the link, which a
|
||
// projection read-back restores after the disposal (rules.md
|
||
// B-28).
|
||
let acquired = self.asset_acquired_date(id).expect("known asset");
|
||
if date < acquired {
|
||
return Err(LedgerError::DisposalBeforeAcquisition {
|
||
date: date.to_string(),
|
||
acquired: acquired.to_string(),
|
||
});
|
||
}
|
||
// Proceeds are a line of the disposal entry, and its date one
|
||
// the books keep (P0 review round 3, S1).
|
||
if !kernel::Amount::in_line_range(proceeds.amount) {
|
||
return Err(LedgerError::AmountOutOfRange);
|
||
}
|
||
crate::calendar::check(date)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Accounting periods
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Create an accounting period. The date range must be valid
|
||
/// (end >= start). Returns the period id.
|
||
///
|
||
/// ```text
|
||
/// GOAL: name a span of days that can later be closed.
|
||
/// SOURCE: rules.md B-12 (IRS Pub. 538: tax years, calendar or fiscal);
|
||
/// DECISION (owner, 2026-10-13): periods nest, never
|
||
/// partially overlap.
|
||
/// SCENARIOS:
|
||
/// 1. IF end is before start: SHOULD refuse.
|
||
/// 2. IF it lies inside, or around, every period it touches: SHOULD
|
||
/// accept (a month in a quarter in a year).
|
||
/// 3. IF it partially overlaps a period:
|
||
/// - SHOULD refuse with PeriodsOverlap
|
||
/// - NEVER create it, or use up an id
|
||
/// BECAUSE a span split across two periods could close twice or
|
||
/// never.
|
||
/// ```
|
||
pub fn create_period(
|
||
&mut self,
|
||
name: &str,
|
||
start: NaiveDate,
|
||
end: NaiveDate,
|
||
) -> Result<PeriodId, LedgerError> {
|
||
periods::validate_period_range(start, end)?;
|
||
let nested = |a: (NaiveDate, NaiveDate), b: (NaiveDate, NaiveDate)| {
|
||
(a.0 >= b.0 && a.1 <= b.1) || (b.0 >= a.0 && b.1 <= a.1)
|
||
};
|
||
for q in self.list_periods() {
|
||
let touches = start <= q.end && q.start <= end;
|
||
if touches && !nested((start, end), (q.start, q.end)) {
|
||
return Err(LedgerError::PeriodsOverlap {
|
||
name: name.to_string(),
|
||
start,
|
||
end,
|
||
other: q.name.clone(),
|
||
other_start: q.start,
|
||
other_end: q.end,
|
||
});
|
||
}
|
||
}
|
||
self.replay_create_period(name, start, end)
|
||
}
|
||
|
||
/// [`Self::create_period`] without the nesting rule added on
|
||
/// 2026-10-13 or the calendar bound added in P0 (only the order is
|
||
/// checked, periods::validate_period_order), for replaying journals
|
||
/// written before them (rules.md B-28).
|
||
pub fn replay_create_period(
|
||
&mut self,
|
||
name: &str,
|
||
start: NaiveDate,
|
||
end: NaiveDate,
|
||
) -> Result<PeriodId, LedgerError> {
|
||
periods::validate_period_order(start, end)?;
|
||
let id = self.next_period_id;
|
||
self.next_period_id += 1;
|
||
self.periods.insert(
|
||
id,
|
||
Period {
|
||
id,
|
||
name: name.to_string(),
|
||
start,
|
||
end,
|
||
status: PeriodStatus::Open,
|
||
closing_entry: None,
|
||
close_seq: None,
|
||
member_close: None,
|
||
members_closed: None,
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Look up a period by id.
|
||
pub fn get_period(&self, id: PeriodId) -> Option<&Period> {
|
||
self.periods.get(&id)
|
||
}
|
||
|
||
/// Ids of every period-closing entry. Period statements (P&L, cash
|
||
/// flow) exclude them: a close moves earnings into retained earnings; it
|
||
/// earns and spends nothing, so counting it would zero out the period.
|
||
pub fn closing_entry_ids(&self) -> std::collections::BTreeSet<EntryId> {
|
||
self.periods
|
||
.values()
|
||
.filter_map(|p| p.closing_entry)
|
||
.collect()
|
||
}
|
||
|
||
/// Whether `entry` is a period's closing entry ([`Self::closing_entry_ids`]
|
||
/// without building the set: O(periods)).
|
||
pub fn is_closing_entry(&self, entry: EntryId) -> bool {
|
||
self.periods
|
||
.values()
|
||
.any(|p| p.closing_entry == Some(entry))
|
||
}
|
||
|
||
/// All periods, in creation order.
|
||
pub fn list_periods(&self) -> Vec<&Period> {
|
||
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.periods.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
|
||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
||
self.close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
|
||
}
|
||
|
||
/// Close a period. This:
|
||
/// 1. Rejects if the period is already closed, if any draft is dated
|
||
/// inside it, if a later period is already closed (closing out of
|
||
/// 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
|
||
/// and expense account's balance *as of the period end* (entries
|
||
/// dated after it stay open for their own period), puts the net in
|
||
/// `to`'s target, and zeroes `to`'s sweep accounts (draws,
|
||
/// distributions, dividends) into their target too:
|
||
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||
/// - `Account`: one equity account (a sole proprietor's capital).
|
||
/// - `Members`: each member's capital account gets their share of
|
||
/// everything closed to these members and shares so far, to the
|
||
/// currency's minor unit (odd units by largest remainder, ties to
|
||
/// the member listed first), less what earlier closes gave them
|
||
/// (rules.md B-13; D-10), and their own distribution account. The
|
||
/// count starts at the first members close, or at the first since
|
||
/// the members or shares last changed (`Ledger::members_regime`);
|
||
/// closes to anything else are skipped. A close that posts an
|
||
/// entry may carry a one-time true-up of rounding by closes made
|
||
/// under an earlier rule (the journaled `MembersRun`,
|
||
/// `MembersPerClose` and `MembersLastAbsorbs`, which replay as
|
||
/// they were made).
|
||
/// 3. Marks the period closed.
|
||
///
|
||
/// Returns the closing entry's id, or `None` if there was nothing to
|
||
/// close.
|
||
///
|
||
/// ```text
|
||
/// GOAL: close a period: zero every revenue and expense account's balance
|
||
/// as of its last day into equity, and lock it.
|
||
/// SOURCE: docs/spec/rules.md B-11 (Pacioli ch. 27, 34), B-12, B-13 (26
|
||
/// U.S.C. §704(a)); R-? Periods and close.
|
||
/// INVARIANTS:
|
||
/// 1. After a close, every revenue and expense account MUST have a zero
|
||
/// balance as of the period's last day.
|
||
/// 2. The closing entry MUST balance, be dated the period's last day, and
|
||
/// credit equity with exactly the net income through that day (split
|
||
/// among members by share, to the currency's minor unit, by largest
|
||
/// remainder, cumulatively).
|
||
/// 3. Entries dated after the period MUST stay untouched (their income
|
||
/// belongs to their own period).
|
||
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
|
||
/// its balance moved into its target.
|
||
/// 5. NEVER post a closing entry when there is nothing to close.
|
||
/// 6. After a members close that posts, each member's total from the
|
||
/// count's closes MUST be one largest-remainder split of everything
|
||
/// they closed: under one minor unit from their exact share. A
|
||
/// members close that posts nothing credits no one (any true-up
|
||
/// waits for the next close that posts).
|
||
/// 7. NEVER changes a posted close: earlier closes replay under the
|
||
/// rule they were journaled with.
|
||
/// SCENARIOS:
|
||
/// 1. IF the period is unknown or already closed: SHOULD refuse.
|
||
/// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod).
|
||
/// 3. IF a later period is already closed: SHOULD refuse (CloseOutOfOrder).
|
||
/// 4. IF a target or sweep account isn't a distinct equity account, or
|
||
/// member shares aren't positive and summing to 1: SHOULD refuse.
|
||
/// 5. IF closing to retained earnings and 3999 exists but isn't a
|
||
/// postable equity account: SHOULD refuse (ControlAccountMismatch).
|
||
/// 6. IF a journaled MembersCumulative names a start other than the
|
||
/// one the books derive: SHOULD refuse (InvalidMembers)
|
||
/// BECAUSE replay would split differently from the journal.
|
||
/// 7. IF 50/50 members closed −287.53 in January (A −143.77, B
|
||
/// −143.76), February closed to retained earnings, and March closes
|
||
/// −287.53 to the members: SHOULD give A −143.76, B −143.77, so
|
||
/// each has −287.53 (the count skips February's close).
|
||
/// ```
|
||
pub fn close_period_to(
|
||
&mut self,
|
||
id: PeriodId,
|
||
to: &periods::CloseTo,
|
||
) -> Result<Option<EntryId>, LedgerError> {
|
||
self.atomically(|l| l.close_period_to_steps(id, to))
|
||
}
|
||
|
||
/// The steps of [`Self::close_period_to`], run inside [`Self::atomically`].
|
||
fn close_period_to_steps(
|
||
&mut self,
|
||
id: PeriodId,
|
||
to: &periods::CloseTo,
|
||
) -> Result<Option<EntryId>, LedgerError> {
|
||
self.preview_close_period_to(id, to)?;
|
||
let period = self.periods.get(&id).expect("previewed").clone();
|
||
let (closings, net) = self.income_closings(period.end);
|
||
let close_seq = self.next_close_seq();
|
||
|
||
// (account, amount to credit; negative debits): the net to its
|
||
// targets, and each sweep account's balance into its target.
|
||
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
|
||
let mut members_closed = None;
|
||
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||
// An equity balance (credit-normal) is −movement; zeroing it
|
||
// moves exactly that much into the target.
|
||
let balance = -this.movement_through(from, period.end);
|
||
if !balance.is_zero() {
|
||
sweep_lines.push((from, into, balance));
|
||
}
|
||
};
|
||
match to {
|
||
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);
|
||
}
|
||
}
|
||
_ => {
|
||
let members = to.members().expect("every other target is members");
|
||
// The sweeps first: whether an entry posts decides whether
|
||
// the parts may carry a true-up. Their lines still follow
|
||
// the parts in the entry.
|
||
for m in members {
|
||
if let Some(d) = m.distributions {
|
||
sweep(d, m.capital, self);
|
||
}
|
||
}
|
||
let posts = !closings.is_empty() || !sweep_lines.is_empty();
|
||
let (parts, rule) = self.members_split(to, net, posts, close_seq)?;
|
||
for (m, part) in members.iter().zip(&parts) {
|
||
equity.push((m.capital, *part));
|
||
}
|
||
members_closed = Some(periods::MembersClosed {
|
||
members: member_key(members),
|
||
net,
|
||
parts,
|
||
rule,
|
||
});
|
||
}
|
||
}
|
||
|
||
let closing_entry = if closings.is_empty() && sweep_lines.is_empty() {
|
||
None
|
||
} else {
|
||
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
||
let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> {
|
||
match kernel::credit_line(credit) {
|
||
Some((side, amount)) => {
|
||
this.add_line(entry, account, side, Money::new(amount, this.base_currency))
|
||
}
|
||
None => Ok(()),
|
||
}
|
||
};
|
||
for (account, side, amount) in closings {
|
||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||
}
|
||
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)?;
|
||
Some(entry)
|
||
};
|
||
|
||
let p = self.periods.get_mut(&id).expect("period exists");
|
||
p.status = PeriodStatus::Closed;
|
||
p.closing_entry = closing_entry;
|
||
p.close_seq = Some(close_seq);
|
||
p.member_close = match to {
|
||
periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord {
|
||
members: member_key(members),
|
||
net,
|
||
}),
|
||
_ => None,
|
||
};
|
||
p.members_closed = members_closed;
|
||
Ok(closing_entry)
|
||
}
|
||
|
||
/// The number the next close takes ([`Period::close_seq`]): one more
|
||
/// than the closes so far.
|
||
fn next_close_seq(&self) -> u64 {
|
||
self.periods
|
||
.values()
|
||
.filter(|p| p.status == PeriodStatus::Closed)
|
||
.count() as u64
|
||
+ 1
|
||
}
|
||
|
||
/// What a members close (`to`, closing `net`; `posts` when its entry
|
||
/// posts; numbered `close_seq`) credits each member, in `to`'s order,
|
||
/// and the rule it is made under: the rule journaled for replayed
|
||
/// closes, else the cumulative rule (rules.md B-13): each member's
|
||
/// split of the count's running total, to the base currency's minor
|
||
/// unit, less what the count's earlier closes gave them. A close that
|
||
/// posts nothing credits nothing, so a true-up of earlier rounding
|
||
/// rides only on an entry that posts (INVARIANT 5 stays true).
|
||
fn members_split(
|
||
&self,
|
||
to: &periods::CloseTo,
|
||
net: Decimal,
|
||
posts: bool,
|
||
close_seq: u64,
|
||
) -> Result<(Vec<Decimal>, periods::MemberRule), LedgerError> {
|
||
use periods::{CloseTo, MemberRule};
|
||
let members = to.members().expect("a members close");
|
||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||
Ok(match to {
|
||
CloseTo::MembersLastAbsorbs(_) => (
|
||
periods::allocate_last_absorbs(net, &shares),
|
||
MemberRule::LastAbsorbs,
|
||
),
|
||
CloseTo::MembersPerClose(_) => (periods::allocate(net, &shares), MemberRule::PerClose),
|
||
CloseTo::MembersRun(_) => {
|
||
// The run rule: what the run gives each member with this
|
||
// close, less what it gave them before.
|
||
let before = self.member_close_run(&member_key(members));
|
||
let after = periods::allocate(before + net, &shares);
|
||
let prior = periods::allocate(before, &shares);
|
||
let parts = after.iter().zip(&prior).map(|(a, p)| a - p).collect();
|
||
(parts, MemberRule::Run)
|
||
}
|
||
_ => {
|
||
let key = member_key(members);
|
||
let regime = self.members_regime(&key, None);
|
||
let since_seq = regime.since_seq.unwrap_or(close_seq);
|
||
let parts = if posts {
|
||
regime.allocate(
|
||
&key,
|
||
regime.closed + net,
|
||
®ime.given,
|
||
self.base_currency.allocation_places(),
|
||
)?
|
||
} else {
|
||
vec![Decimal::ZERO; members.len()]
|
||
};
|
||
(parts, MemberRule::Cumulative { since_seq })
|
||
}
|
||
})
|
||
}
|
||
|
||
/// The members regime a close with `members` ((capital account, share)
|
||
/// pairs) belongs to (rules.md B-13), counting only closes whose
|
||
/// period ends on or before `through` (all when `None`): walking the
|
||
/// closes newest first by [`Period::close_seq`], members closes under
|
||
/// any rule with the same SET of (capital, share) pairs (order and
|
||
/// distribution accounts ignored; 0.5 is 0.50), skipping closes to
|
||
/// anything else (they allocate nothing to members), and stopping at
|
||
/// the first members close with another set. A change of members or
|
||
/// shares starts a new count (26 U.S.C. §706(d)(1); Treas. Reg.
|
||
/// §1.706-4(a)(3)(vii): each segment its own distributive share
|
||
/// period); income closed under the old shares is never re-split.
|
||
///
|
||
/// The date filter keeps a prefix of the closes in close order because
|
||
/// period ends rise with close order: a close is refused while a later
|
||
/// period is closed (CloseOutOfOrder) or when its end lies in a closed
|
||
/// period (`preview_close_period_to`), pinned by
|
||
/// `period_ends_rise_with_close_order`.
|
||
///
|
||
/// As of a day no count with `members` holds yet, the earnings not
|
||
/// closed by then go to the count the next members close begins, when
|
||
/// it is these members': its first close's order is the regime's
|
||
/// [`Regime::order`], so their tied units go as they will once it
|
||
/// counts them, and a reorder of the members in the settings since
|
||
/// moves no cent on those days either.
|
||
pub(crate) fn members_regime(
|
||
&self,
|
||
members: &[(AccountId, Decimal)],
|
||
through: Option<NaiveDate>,
|
||
) -> Regime {
|
||
let mut regime = self.regime_where(members, |p| through.is_none_or(|d| p.end <= d));
|
||
if let Some(day) = through
|
||
&& regime.order.is_empty()
|
||
{
|
||
let next = self
|
||
.periods
|
||
.values()
|
||
.filter(|p| p.status == PeriodStatus::Closed && p.end > day)
|
||
.filter_map(|p| Some((p.close_seq, p.members_closed.as_ref()?)))
|
||
.min_by_key(|&(seq, _)| seq);
|
||
if let Some((_, m)) = next
|
||
&& same_member_set(&m.members, members)
|
||
{
|
||
regime.order.clone_from(&m.members);
|
||
}
|
||
}
|
||
regime
|
||
}
|
||
|
||
/// [`Self::members_regime`] over the closed periods `include` keeps.
|
||
fn regime_where(
|
||
&self,
|
||
members: &[(AccountId, Decimal)],
|
||
include: impl Fn(&Period) -> bool,
|
||
) -> Regime {
|
||
let mut closed: Vec<&Period> = self
|
||
.periods
|
||
.values()
|
||
.filter(|p| p.status == PeriodStatus::Closed && include(p))
|
||
.collect();
|
||
closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq));
|
||
let mut regime = Regime {
|
||
since_seq: None,
|
||
closed: Decimal::ZERO,
|
||
given: vec![Decimal::ZERO; members.len()],
|
||
order: Vec::new(),
|
||
};
|
||
for p in closed {
|
||
let Some(m) = &p.members_closed else {
|
||
continue;
|
||
};
|
||
if !same_member_set(&m.members, members) {
|
||
break;
|
||
}
|
||
regime.since_seq = p.close_seq;
|
||
regime.order.clone_from(&m.members);
|
||
regime.closed += m.net;
|
||
for ((capital, _), part) in m.members.iter().zip(&m.parts) {
|
||
if let Some(i) = members.iter().position(|(c, _)| c == capital) {
|
||
regime.given[i] += *part;
|
||
}
|
||
}
|
||
}
|
||
regime
|
||
}
|
||
|
||
/// A closed members period's split under the cumulative rule
|
||
/// (books.close_period's `members`): the close its count starts at,
|
||
/// and per member, in the close's order, what this close credited
|
||
/// them, their running total from the count's closes through it, and
|
||
/// their exact share of everything the count closed through it. `None`
|
||
/// for a period not closed to members, or closed under an earlier
|
||
/// rule.
|
||
pub fn member_close_split(&self, id: PeriodId) -> Option<(u64, Vec<periods::MemberSplit>)> {
|
||
let p = self.periods.get(&id)?;
|
||
let m = p.members_closed.as_ref()?;
|
||
let periods::MemberRule::Cumulative { since_seq } = m.rule else {
|
||
return None;
|
||
};
|
||
let seq = p.close_seq?;
|
||
let regime = self.regime_where(&m.members, |q| q.close_seq <= Some(seq));
|
||
let split = m
|
||
.members
|
||
.iter()
|
||
.zip(&m.parts)
|
||
.zip(®ime.given)
|
||
.map(
|
||
|((&(capital, share), &part), &running)| periods::MemberSplit {
|
||
capital,
|
||
share,
|
||
part,
|
||
running,
|
||
exact: share * regime.closed,
|
||
},
|
||
)
|
||
.collect();
|
||
Some((since_seq, split))
|
||
}
|
||
|
||
/// The net closed by the run of cumulative members closes, with exactly
|
||
/// these members and shares, that ends with the latest close: zero if
|
||
/// the latest close was anything else (or there is none).
|
||
fn member_close_run(&self, members: &[(AccountId, Decimal)]) -> Decimal {
|
||
let mut closed: Vec<&periods::Period> = self
|
||
.periods
|
||
.values()
|
||
.filter(|p| p.status == PeriodStatus::Closed)
|
||
.collect();
|
||
closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq));
|
||
let mut total = Decimal::ZERO;
|
||
for p in closed {
|
||
match &p.member_close {
|
||
Some(r) if r.members == members => total += r.net,
|
||
_ => break,
|
||
}
|
||
}
|
||
total
|
||
}
|
||
|
||
/// Revenue and expense balances as of `end` as closing lines (code
|
||
/// order), and the net income they carry (revenue debits minus expense
|
||
/// credits).
|
||
fn income_closings(&self, end: NaiveDate) -> (Vec<(AccountId, Side, Decimal)>, Decimal) {
|
||
let mut closings = Vec::new();
|
||
let mut accounts: Vec<_> = self.chart.all().collect();
|
||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||
for account in accounts {
|
||
if !matches!(
|
||
account.account_type,
|
||
AccountType::Revenue | AccountType::Expense
|
||
) {
|
||
continue;
|
||
}
|
||
// To zero the account: post the opposite of its net movement.
|
||
if let Some((side, amount)) =
|
||
kernel::closing_line(self.movement_through(account.id, end))
|
||
{
|
||
closings.push((account.id, side, amount));
|
||
}
|
||
}
|
||
let net = kernel::net(closings.iter().map(|&(_, side, amount)| (side, amount)))
|
||
.expect("posted lines are in range, so their sums are");
|
||
(closings, net)
|
||
}
|
||
|
||
fn retained_earnings_account(&mut self) -> Result<AccountId, LedgerError> {
|
||
if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) {
|
||
return Ok(id);
|
||
}
|
||
Ok(match self.chart.find_by_code(RETAINED_EARNINGS.code) {
|
||
Some(a) => a.id,
|
||
None => self.add_control_account(&RETAINED_EARNINGS)?,
|
||
})
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
_ => {
|
||
let members = to.members().expect("every other target is members");
|
||
if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to {
|
||
return Err(LedgerError::InvalidMembers(
|
||
"a count of members closes starts at close 1 or later, not 0".into(),
|
||
));
|
||
}
|
||
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();
|
||
for a in accounts {
|
||
let account = self.chart.get(a).ok_or(LedgerError::UnknownAccount(a))?;
|
||
if account.account_type != AccountType::Equity || !seen.insert(a) {
|
||
return Err(LedgerError::InvalidMembers(format!(
|
||
"close account {} must be a distinct equity account",
|
||
account.code
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Debits minus credits on an account across posted entries dated on or
|
||
/// before `end`.
|
||
fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal {
|
||
self.net_of(account, |e| e.date() <= end)
|
||
}
|
||
|
||
/// Debits minus credits on an account across the posted entries
|
||
/// `include` accepts.
|
||
fn net_of(&self, account: AccountId, include: impl Fn(&JournalEntry) -> bool) -> Decimal {
|
||
kernel::net(
|
||
self.posted_entries()
|
||
.filter(|e| include(e))
|
||
.flat_map(|e| e.lines())
|
||
.filter(|l| l.account == account)
|
||
.map(|l| (l.side, l.amount.amount)),
|
||
)
|
||
.expect("posted lines are in range, so their sums are")
|
||
}
|
||
|
||
/// [`Self::preview_close_period_to`] closing into Retained Earnings.
|
||
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 (for members: their split computes exactly, and a
|
||
/// journaled start is the one the books derive) — 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
|
||
.periods
|
||
.get(&id)
|
||
.ok_or(LedgerError::UnknownPeriod(id))?;
|
||
if period.status == PeriodStatus::Closed {
|
||
return Err(LedgerError::PeriodAlreadyClosed(id));
|
||
}
|
||
let has_drafts = self
|
||
.entries
|
||
.values()
|
||
.any(|e| e.status() == EntryStatus::Draft && period.contains(e.date()));
|
||
if has_drafts {
|
||
return Err(LedgerError::DraftsInPeriod(id));
|
||
}
|
||
if let Some(other) = self.closed_period_containing(period.end)
|
||
&& other != id
|
||
{
|
||
return Err(LedgerError::ClosedPeriod(other, period.end.to_string()));
|
||
}
|
||
// Periods close in order: a later closed period already swept this
|
||
// one's income, so closing it now would sweep it again.
|
||
if let Some(later) = self
|
||
.periods
|
||
.values()
|
||
.find(|p| p.status == PeriodStatus::Closed && p.end > period.end)
|
||
{
|
||
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
||
}
|
||
self.validate_close_to(to)?;
|
||
if matches!(to, periods::CloseTo::RetainedEarnings { .. }) {
|
||
self.check_controls(&[RETAINED_EARNINGS])?;
|
||
}
|
||
// An entry posts exactly when there is income to close or a sweep
|
||
// account with a balance. Account creation (Retained Earnings) does
|
||
// not consume entry ids.
|
||
let (closings, net) = self.income_closings(period.end);
|
||
let sweeps: Vec<AccountId> = match to {
|
||
periods::CloseTo::RetainedEarnings { sweep }
|
||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||
_ => to
|
||
.members()
|
||
.expect("every other target is 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());
|
||
if to.members().is_some() {
|
||
// The journaled start must be the derived one, and the split
|
||
// must be computable: a close this refuses journals nothing.
|
||
let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?;
|
||
if let (
|
||
periods::CloseTo::MembersCumulative { since_seq, .. },
|
||
periods::MemberRule::Cumulative { since_seq: derived },
|
||
) = (to, rule)
|
||
&& *since_seq != derived
|
||
{
|
||
return Err(LedgerError::InvalidMembers(format!(
|
||
"this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}"
|
||
)));
|
||
}
|
||
}
|
||
Ok(any.then_some(self.next_entry_id))
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Accounts receivable / payable
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Add a customer. Returns its id.
|
||
pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, LedgerError> {
|
||
if name.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAssetDescription);
|
||
}
|
||
// Its party takes this name, so the name obeys party rules (no
|
||
// full TIN; parties INVARIANT 1).
|
||
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
|
||
let id = self.next_customer_id;
|
||
self.next_customer_id += 1;
|
||
self.customers.insert(
|
||
id,
|
||
Customer {
|
||
id,
|
||
name: name.to_string(),
|
||
},
|
||
);
|
||
self.link_record_party(name, false, id);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Add a vendor. Returns its id.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a vendor record, linked to its party in the directory.
|
||
/// SOURCE: docs/parties-and-attachments.md; crate::shares INVARIANT 5
|
||
/// (at most one party on an entry has a vendor record);
|
||
/// Self::set_entry_parties INVARIANT 2 (the vendor tag follows
|
||
/// the one party with a vendor record).
|
||
/// INVARIANTS:
|
||
/// 1. The record links to the live party of that name without one,
|
||
/// else a new party (Self::link_record_party).
|
||
/// 2. IF that party is named on an entry beside another party with a
|
||
/// vendor record: SHOULD refuse, changing nothing (the entry would
|
||
/// name two vendors). Record each vendor's part as its own entry
|
||
/// first.
|
||
/// 3. Every entry naming that party with no vendor tag is tagged with
|
||
/// the new vendor, as set_entry_parties would have tagged it.
|
||
/// ```
|
||
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, LedgerError> {
|
||
if name.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAssetDescription);
|
||
}
|
||
// Its party takes this name, so the name obeys party rules (no
|
||
// full TIN; parties INVARIANT 1).
|
||
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
|
||
if let Some(p) = self.record_party_match(name, true) {
|
||
let mut ids: Vec<EntryId> = self.entry_parties.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
if let Some(e) = ids.into_iter().find(|e| {
|
||
let named = self.entry_parties(*e);
|
||
named.iter().any(|s| s.party == p)
|
||
&& named
|
||
.iter()
|
||
.any(|s| s.party != p && self.parties[&s.party].vendor.is_some())
|
||
}) {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"party {p} ({}) is named on entry {e} beside another party with a vendor record; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry before adding this vendor",
|
||
self.parties[&p].name
|
||
)));
|
||
}
|
||
}
|
||
let id = self.next_vendor_id;
|
||
self.next_vendor_id += 1;
|
||
self.vendors.insert(
|
||
id,
|
||
Vendor {
|
||
id,
|
||
name: name.to_string(),
|
||
decisions_1099: Vec::new(),
|
||
tin_on_file: false,
|
||
},
|
||
);
|
||
self.link_record_party(name, true, id);
|
||
// INVARIANT 3: the tag follows the party's new vendor record.
|
||
if let Some(p) = self.party_of_vendor(id) {
|
||
let untagged: Vec<EntryId> = self
|
||
.entry_parties
|
||
.iter()
|
||
.filter(|(e, named)| {
|
||
named.iter().any(|s| s.party == p) && !self.entry_vendors.contains_key(e)
|
||
})
|
||
.map(|(e, _)| *e)
|
||
.collect();
|
||
for e in untagged {
|
||
self.entry_vendors.insert(e, id);
|
||
}
|
||
}
|
||
Ok(id)
|
||
}
|
||
|
||
/// Look up a customer.
|
||
pub fn get_customer(&self, id: CustomerId) -> Option<&Customer> {
|
||
self.customers.get(&id)
|
||
}
|
||
|
||
/// Look up a vendor.
|
||
pub fn get_vendor(&self, id: VendorId) -> Option<&Vendor> {
|
||
self.vendors.get(&id)
|
||
}
|
||
|
||
/// Check a 1099 decision for a vendor before it is recorded
|
||
/// ([`crate::form1099::check_new_decision`]): the vendor exists, the
|
||
/// decision, its source and its date are sound, and a dated decision
|
||
/// records a change. Returns what recording it would do.
|
||
pub fn check_vendor_1099(
|
||
&self,
|
||
id: VendorId,
|
||
new: &crate::form1099::Dated1099,
|
||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||
let v = self
|
||
.vendors
|
||
.get(&id)
|
||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||
crate::form1099::check_new_decision(&v.decisions_1099, new)
|
||
.map_err(LedgerError::Invalid1099)
|
||
}
|
||
|
||
/// Record a vendor's 1099 decision from the date it takes effect (or
|
||
/// from the beginning), with who decided, and whether its W-9 is on
|
||
/// file, checked ([`Self::check_vendor_1099`]).
|
||
///
|
||
/// ```text
|
||
/// GOAL: "no 1099" is a decision kept with its reason and who made it,
|
||
/// never the same as "never decided" (plan defects 18, 23), and
|
||
/// each payment takes the decision in force on its date (D-40).
|
||
/// SOURCE: docs/spec/rules.md B-19; crate::form1099::plan_decision.
|
||
/// SCENARIOS:
|
||
/// 1. IF the vendor is unknown, or the decision fails its checks:
|
||
/// SHOULD refuse, changing nothing.
|
||
/// 2. Otherwise SHOULD record it (crate::form1099::apply_decision)
|
||
/// and set the W-9 flag, returning what it did.
|
||
/// ```
|
||
pub fn set_vendor_1099(
|
||
&mut self,
|
||
id: VendorId,
|
||
new: crate::form1099::Dated1099,
|
||
tin_on_file: bool,
|
||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||
self.check_vendor_1099(id, &new)?;
|
||
self.replay_vendor_1099(id, new, tin_on_file)
|
||
}
|
||
|
||
/// [`Self::set_vendor_1099`] as journaled: the vendor must exist, and
|
||
/// the decision is recorded as the line says, without the checks a
|
||
/// new decision passes (rules.md B-28). A line written before
|
||
/// decisions were kept replays as a decision from the beginning with
|
||
/// no source (a null as undetermined: plan 5.5).
|
||
pub fn replay_vendor_1099(
|
||
&mut self,
|
||
id: VendorId,
|
||
new: crate::form1099::Dated1099,
|
||
tin_on_file: bool,
|
||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||
let v = self
|
||
.vendors
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||
let applied = crate::form1099::apply_decision(&mut v.decisions_1099, new);
|
||
v.tin_on_file = tin_on_file;
|
||
Ok(applied)
|
||
}
|
||
|
||
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
|
||
/// and who made them (P3 review R8). Returns whether it changed.
|
||
pub fn set_vendor_w9_flag(&mut self, id: VendorId, on_file: bool) -> Result<bool, LedgerError> {
|
||
let v = self
|
||
.vendors
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||
let changed = v.tin_on_file != on_file;
|
||
v.tin_on_file = on_file;
|
||
Ok(changed)
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Payment methods (crate::instruments)
|
||
// ------------------------------------------------------------------
|
||
|
||
/// The id the next payment method gets.
|
||
pub fn next_instrument_id(&self) -> crate::InstrumentId {
|
||
self.next_instrument_id
|
||
}
|
||
|
||
/// Check a new payment method (crate::instruments INVARIANTS 1-4).
|
||
/// Returns its label, trimmed.
|
||
pub fn check_add_instrument(
|
||
&self,
|
||
label: &str,
|
||
last4: Option<&str>,
|
||
owner_party: Option<crate::PartyId>,
|
||
account: AccountId,
|
||
) -> Result<String, LedgerError> {
|
||
let label =
|
||
crate::instruments::check_label(label).map_err(LedgerError::InvalidInstrument)?;
|
||
let key = crate::instruments::label_key(&label);
|
||
if let Some(i) = self
|
||
.instruments
|
||
.values()
|
||
.find(|i| crate::instruments::label_key(&i.label) == key)
|
||
{
|
||
return Err(LedgerError::InvalidInstrument(format!(
|
||
"payment method {} is already named {:?}: give this one another name",
|
||
i.id, i.label
|
||
)));
|
||
}
|
||
if let Some(l) = last4 {
|
||
crate::instruments::check_last4(l).map_err(LedgerError::InvalidInstrument)?;
|
||
}
|
||
if let Some(p) = owner_party
|
||
&& !self.parties.contains_key(&p)
|
||
{
|
||
return Err(LedgerError::UnknownParty(p));
|
||
}
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if a.placeholder {
|
||
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
|
||
}
|
||
if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) {
|
||
return Err(LedgerError::InvalidInstrument(format!(
|
||
"account {} {:?} is {:?}: a payment method pays through an asset (a bank account) or a liability (a card, or what's owed to the members)",
|
||
a.code, a.name, a.account_type
|
||
)));
|
||
}
|
||
let control = [&crate::PAYABLES, &crate::RECEIVABLES]
|
||
.into_iter()
|
||
.find(|c| self.role_account(c) == Some(account));
|
||
if let Some(c) = control {
|
||
return Err(LedgerError::InvalidInstrument(format!(
|
||
"account {} is the {} control account, which no payment is made from: name the bank, card, or members' account the money left",
|
||
a.code, c.role
|
||
)));
|
||
}
|
||
Ok(label)
|
||
}
|
||
|
||
/// Add a payment method ([`Self::check_add_instrument`]). Returns its
|
||
/// id.
|
||
pub fn add_instrument(
|
||
&mut self,
|
||
kind: crate::InstrumentKind,
|
||
label: &str,
|
||
last4: Option<&str>,
|
||
owner_party: Option<crate::PartyId>,
|
||
account: AccountId,
|
||
) -> Result<crate::InstrumentId, LedgerError> {
|
||
let label = self.check_add_instrument(label, last4, owner_party, account)?;
|
||
let id = self.next_instrument_id;
|
||
self.next_instrument_id += 1;
|
||
self.instruments.insert(
|
||
id,
|
||
crate::Instrument {
|
||
id,
|
||
kind,
|
||
label,
|
||
last4: last4.map(str::to_string),
|
||
owner_party,
|
||
account,
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Every payment method, in id order.
|
||
pub fn instruments(&self) -> impl Iterator<Item = &crate::Instrument> + '_ {
|
||
self.instruments.values()
|
||
}
|
||
|
||
/// One payment method.
|
||
pub fn get_instrument(&self, id: crate::InstrumentId) -> Option<&crate::Instrument> {
|
||
self.instruments.get(&id)
|
||
}
|
||
|
||
/// The liability accounts that are company cards: the account of each
|
||
/// business-card payment method that is a liability (a credit card's
|
||
/// balance; a debit card draws on an asset and isn't one). A member's
|
||
/// card pays through the members' financing account, and a platform
|
||
/// through its own balance: neither is a card account. For whatever
|
||
/// needs to tell a card from other liabilities (party money, the cash
|
||
/// flow's sections), from what the books record rather than an
|
||
/// account's name.
|
||
pub fn card_accounts(&self) -> std::collections::BTreeSet<AccountId> {
|
||
self.instruments
|
||
.values()
|
||
.filter(|i| i.kind == crate::InstrumentKind::BusinessCard)
|
||
.filter(|i| {
|
||
self.chart
|
||
.get(i.account)
|
||
.is_some_and(|a| a.account_type == AccountType::Liability)
|
||
})
|
||
.map(|i| i.account)
|
||
.collect()
|
||
}
|
||
|
||
/// The payment method with this label (ignoring case and surrounding
|
||
/// space).
|
||
pub fn instrument_by_label(&self, label: &str) -> Option<&crate::Instrument> {
|
||
let key = crate::instruments::label_key(label);
|
||
self.instruments
|
||
.values()
|
||
.find(|i| crate::instruments::label_key(&i.label) == key)
|
||
}
|
||
|
||
/// The payment method an entry is tagged with.
|
||
pub fn entry_instrument(&self, entry: EntryId) -> Option<crate::InstrumentId> {
|
||
self.entry_instruments.get(&entry).copied()
|
||
}
|
||
|
||
/// Tag an entry (draft or posted) with the payment method it was paid
|
||
/// with, or clear the tag.
|
||
///
|
||
/// ```text
|
||
/// GOAL: the 1099 worksheet knows how each payment was made.
|
||
/// SOURCE: docs/spec/rules.md B-19; crate::instruments.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry or the payment method is unknown: SHOULD refuse.
|
||
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
||
/// entries, like the vendor tag).
|
||
/// 3. The entry's reversal chain carries the same tag, so a payment
|
||
/// and its reversal always sit in the same bucket and net to zero.
|
||
/// ```
|
||
pub fn set_entry_instrument(
|
||
&mut self,
|
||
entry: EntryId,
|
||
instrument: Option<crate::InstrumentId>,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
if let Some(i) = instrument
|
||
&& !self.instruments.contains_key(&i)
|
||
{
|
||
return Err(LedgerError::UnknownInstrument(i));
|
||
}
|
||
for e in self.reversal_chain(entry) {
|
||
match instrument {
|
||
Some(i) => self.entry_instruments.insert(e, i),
|
||
None => self.entry_instruments.remove(&e),
|
||
};
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Tag an entry (draft or posted) with the vendor it paid or was billed
|
||
/// by, or clear the tag. The tag is reporting metadata, like a receipt
|
||
/// link: it changes no amounts, so posted entries may be tagged.
|
||
///
|
||
/// ```text
|
||
/// GOAL: tag an entry with the vendor it paid, for 1099s.
|
||
/// SOURCE: docs/spec/rules.md B-19; Self::set_entry_parties
|
||
/// INVARIANTS 2 and 5 (the vendor tag follows the parties, never
|
||
/// the other way round, on an entry naming several).
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry or vendor is unknown: SHOULD refuse.
|
||
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
||
/// entries).
|
||
/// 3. On an entry naming no party or one: Some(v) makes v's party the
|
||
/// entry's one party (all of it) and clears a no-party mark
|
||
/// (set_entry_parties INVARIANT 3: an entry never names a party
|
||
/// and carries the mark at once); None clears the party too when
|
||
/// it was the vendor's, and leaves no mark.
|
||
/// 4. On an entry naming several parties, Some(v) with v's party
|
||
/// among them sets the tag and keeps every party and share as
|
||
/// they are. IF v's party is not among them (or v has none):
|
||
/// SHOULD refuse, changing nothing: naming v alone would drop the
|
||
/// others' shares and count all of it toward v's 1099. The list
|
||
/// is Self::set_entry_parties's to change.
|
||
/// 5. On an entry naming several parties, one of them the tagged
|
||
/// vendor's party, None SHOULD refuse, changing nothing: the tag
|
||
/// follows that party (set_entry_parties INVARIANT 2), so the
|
||
/// 1099 would drop a payment the party still shows. Drop the party
|
||
/// with Self::set_entry_parties instead.
|
||
/// 6. The entry's reversal chain (an entry, its reversal, theirs)
|
||
/// carries the same tags (set_entry_parties INVARIANT 5).
|
||
/// ```
|
||
pub fn set_entry_vendor(
|
||
&mut self,
|
||
entry: EntryId,
|
||
vendor: Option<VendorId>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_entry_vendor(entry, vendor)?;
|
||
for e in self.reversal_chain(entry) {
|
||
self.tag_entry_vendor(e, vendor);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_entry_vendor`] (SCENARIOS 1, 4,
|
||
/// 5).
|
||
pub fn check_set_entry_vendor(
|
||
&self,
|
||
entry: EntryId,
|
||
vendor: Option<VendorId>,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
let named = self.entry_parties(entry);
|
||
match vendor {
|
||
Some(v) => {
|
||
if !self.vendors.contains_key(&v) {
|
||
return Err(LedgerError::UnknownVendor(v));
|
||
}
|
||
let p = self.party_of_vendor(v);
|
||
if named.len() >= 2 && !named.iter().any(|s| Some(s.party) == p) {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"entry {entry} names {} parties and vendor {v}'s party isn't one of them; tagging the vendor would drop their shares: name the parties with books.set_entry_party (parties) instead",
|
||
named.len()
|
||
)));
|
||
}
|
||
}
|
||
None => {
|
||
if named.len() >= 2
|
||
&& let Some(p) = self
|
||
.entry_vendor(entry)
|
||
.and_then(|v| self.party_of_vendor(v))
|
||
&& named.iter().any(|s| s.party == p)
|
||
{
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"entry {entry} names party {p}, whose vendor record is its vendor tag, among {} parties; the tag follows that party: drop it from the parties with books.set_entry_party (parties) instead",
|
||
named.len()
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// One entry's half of [`Self::set_entry_vendor`], checked already.
|
||
fn tag_entry_vendor(&mut self, entry: EntryId, vendor: Option<VendorId>) {
|
||
match vendor {
|
||
Some(v) => {
|
||
self.entry_vendors.insert(entry, v);
|
||
// Several parties: the list stays (SCENARIO 4).
|
||
if self.entry_parties(entry).len() < 2
|
||
&& let Some(p) = self.party_of_vendor(v)
|
||
{
|
||
self.entry_parties
|
||
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||
}
|
||
if self.entry_parties.contains_key(&entry) {
|
||
self.entry_no_party.remove(&entry);
|
||
}
|
||
}
|
||
None => {
|
||
// The party tag goes too only when it was the vendor's.
|
||
if let Some(v) = self.entry_vendors.remove(&entry)
|
||
&& self.party_of_vendor(v).is_some()
|
||
&& self.entry_party(entry) == self.party_of_vendor(v)
|
||
{
|
||
self.entry_parties.remove(&entry);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An entry's reversal chain, in order: the original it reverses (and
|
||
/// so on up), itself, the entry reversing it (and so on down). An
|
||
/// entry reversed by none and reversing none is its own chain. Each
|
||
/// entry is reversed at most once ([`Self::check_reversal`]).
|
||
pub fn reversal_chain(&self, entry: EntryId) -> Vec<EntryId> {
|
||
let mut top = entry;
|
||
while let Some(o) = self.entries.get(&top).and_then(JournalEntry::reversal_of) {
|
||
if !self.entries.contains_key(&o) || o == entry {
|
||
break;
|
||
}
|
||
top = o;
|
||
}
|
||
let mut chain = vec![top];
|
||
let mut cur = top;
|
||
while let Some(&r) = self.reversed_by.get(&cur) {
|
||
if chain.contains(&r) {
|
||
break;
|
||
}
|
||
chain.push(r);
|
||
cur = r;
|
||
}
|
||
chain
|
||
}
|
||
|
||
/// Tag an entry with a vendor and that vendor's party (bills, bill
|
||
/// payments): one counterparty, both tags.
|
||
fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) {
|
||
self.entry_vendors.insert(entry, vendor);
|
||
if let Some(p) = self.party_of_vendor(vendor) {
|
||
self.entry_parties
|
||
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||
}
|
||
}
|
||
|
||
/// Tag an entry with a customer's party (invoices, invoice payments).
|
||
fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) {
|
||
if let Some(p) = self.party_of_customer(customer) {
|
||
self.entry_parties
|
||
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||
}
|
||
}
|
||
|
||
/// Link a new vendor or customer record to its party: the party not
|
||
/// archived with that name (ignoring case and surrounding space) that
|
||
/// has no such record yet, else a new party (named "Name (2)", "(3)",
|
||
/// ... when a live party already has the name). Deterministic, so replay
|
||
/// rebuilds the same directory from the same records.
|
||
fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) {
|
||
let id = match self.record_party_match(name, vendor) {
|
||
Some(id) => id,
|
||
None => {
|
||
let id = self.next_party_id;
|
||
self.next_party_id += 1;
|
||
// Names are unique among live parties (parties INVARIANT 3):
|
||
// a second record of the same name gets "Name (2)", and so on.
|
||
let base = name.trim();
|
||
let taken = |n: &str| {
|
||
let k = crate::parties::name_key(n);
|
||
self.parties
|
||
.values()
|
||
.any(|p| !p.archived && crate::parties::name_key(&p.name) == k)
|
||
};
|
||
let mut party_name = base.to_string();
|
||
let mut n = 2;
|
||
while taken(&party_name) {
|
||
party_name = format!("{base} ({n})");
|
||
n += 1;
|
||
}
|
||
self.parties.insert(
|
||
id,
|
||
crate::Party {
|
||
id,
|
||
name: party_name,
|
||
display_name: None,
|
||
roles: std::collections::BTreeSet::new(),
|
||
website: None,
|
||
email: None,
|
||
default_terms_as_customer: None,
|
||
default_terms_as_vendor: None,
|
||
address: None,
|
||
notes: None,
|
||
archived: false,
|
||
w9: None,
|
||
w4: None,
|
||
tin: None,
|
||
vendor: None,
|
||
customer: None,
|
||
creditor_of: std::collections::BTreeSet::new(),
|
||
},
|
||
);
|
||
id
|
||
}
|
||
};
|
||
let p = self.parties.get_mut(&id).expect("found or made above");
|
||
if vendor {
|
||
p.vendor = Some(record);
|
||
} else {
|
||
p.customer = Some(record);
|
||
}
|
||
}
|
||
|
||
/// The existing party a new vendor (or customer) record named `name`
|
||
/// links to ([`Self::link_record_party`]): the party not archived with
|
||
/// that name and no such record yet.
|
||
fn record_party_match(&self, name: &str, vendor: bool) -> Option<crate::PartyId> {
|
||
let key = crate::parties::name_key(name);
|
||
let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer };
|
||
self.parties
|
||
.values()
|
||
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none())
|
||
.map(|p| p.id)
|
||
}
|
||
|
||
/// The party a vendor record belongs to.
|
||
pub fn party_of_vendor(&self, vendor: VendorId) -> Option<crate::PartyId> {
|
||
self.parties
|
||
.values()
|
||
.find(|p| p.vendor == Some(vendor))
|
||
.map(|p| p.id)
|
||
}
|
||
|
||
/// The party a customer record belongs to.
|
||
pub fn party_of_customer(&self, customer: CustomerId) -> Option<crate::PartyId> {
|
||
self.parties
|
||
.values()
|
||
.find(|p| p.customer == Some(customer))
|
||
.map(|p| p.id)
|
||
}
|
||
|
||
/// One party.
|
||
pub fn get_party(&self, id: crate::PartyId) -> Option<&crate::Party> {
|
||
self.parties.get(&id)
|
||
}
|
||
|
||
/// Every party, in id order.
|
||
pub fn parties(&self) -> impl Iterator<Item = &crate::Party> + '_ {
|
||
self.parties.values()
|
||
}
|
||
|
||
/// The party (not archived) named `name`, ignoring case and surrounding
|
||
/// space.
|
||
pub fn party_by_name(&self, name: &str) -> Option<&crate::Party> {
|
||
let key = crate::parties::name_key(name);
|
||
self.parties
|
||
.values()
|
||
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key)
|
||
}
|
||
|
||
/// The sole party an entry is with: Some only when it names exactly
|
||
/// one ([`Self::entry_parties`] lists them all).
|
||
pub fn entry_party(&self, entry: EntryId) -> Option<crate::PartyId> {
|
||
match self.entry_parties.get(&entry).map(Vec::as_slice) {
|
||
Some([one]) => Some(one.party),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Every party an entry is with, each with their share, in the order
|
||
/// named (empty when it names none). Resolve the shares to amounts with
|
||
/// [`crate::shares::resolve`].
|
||
pub fn entry_parties(&self, entry: EntryId) -> &[crate::PartyShare] {
|
||
self.entry_parties
|
||
.get(&entry)
|
||
.map(Vec::as_slice)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Whether an entry names `party`, alone or among others.
|
||
pub fn entry_names_party(&self, entry: EntryId, party: crate::PartyId) -> bool {
|
||
self.entry_parties(entry).iter().any(|s| s.party == party)
|
||
}
|
||
|
||
/// The next party id the directory will assign.
|
||
pub fn next_party_id(&self) -> crate::PartyId {
|
||
self.next_party_id
|
||
}
|
||
|
||
/// Add a party.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a record for anyone the company deals with.
|
||
/// SOURCE: crate::parties (INVARIANTS 1, 3); DECISION (owner,
|
||
/// 2026-10-15).
|
||
/// SCENARIOS:
|
||
/// 1. IF the name is blank, holds a full TIN, or names a party not
|
||
/// archived: SHOULD refuse, changing nothing.
|
||
/// 2. IF a role is vendor or customer: SHOULD refuse (those follow
|
||
/// their records; add_vendor / add_customer make them).
|
||
/// ```
|
||
pub fn add_party(
|
||
&mut self,
|
||
name: &str,
|
||
roles: &[crate::Role],
|
||
) -> Result<crate::PartyId, LedgerError> {
|
||
self.check_add_party(name, roles)?;
|
||
let id = self.next_party_id;
|
||
self.next_party_id += 1;
|
||
self.parties.insert(
|
||
id,
|
||
crate::Party {
|
||
id,
|
||
name: name.trim().to_string(),
|
||
display_name: None,
|
||
roles: roles.iter().copied().collect(),
|
||
website: None,
|
||
email: None,
|
||
default_terms_as_customer: None,
|
||
default_terms_as_vendor: None,
|
||
address: None,
|
||
notes: None,
|
||
archived: false,
|
||
w9: None,
|
||
w4: None,
|
||
tin: None,
|
||
vendor: None,
|
||
customer: None,
|
||
creditor_of: std::collections::BTreeSet::new(),
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// The validation behind [`Self::add_party`].
|
||
pub fn check_add_party(&self, name: &str, roles: &[crate::Role]) -> Result<(), LedgerError> {
|
||
let bad = |why: String| Err(LedgerError::InvalidParty(why));
|
||
if name.trim().is_empty() {
|
||
return bad("a party needs a name".into());
|
||
}
|
||
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
|
||
if self.party_by_name(name).is_some() {
|
||
return bad(format!("a party named {:?} already exists", name.trim()));
|
||
}
|
||
if roles
|
||
.iter()
|
||
.any(|r| matches!(r, crate::Role::Vendor | crate::Role::Customer))
|
||
{
|
||
return bad(
|
||
"vendor and customer roles come with their records: add_vendor or add_customer"
|
||
.into(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Change one of a party's facts.
|
||
///
|
||
/// ```text
|
||
/// SOURCE: crate::parties (INVARIANTS 1, 3).
|
||
/// SCENARIOS:
|
||
/// 1. IF the party is unknown, a text holds a full TIN or is too long,
|
||
/// or a new name is blank or taken: SHOULD refuse, changing
|
||
/// nothing.
|
||
/// ```
|
||
pub fn update_party(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
field: crate::PartyField,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_update_party(id, &field)?;
|
||
let p = self.parties.get_mut(&id).expect("checked");
|
||
use crate::PartyField::*;
|
||
match field {
|
||
Name(n) => {
|
||
// Its records carry the same name: aging, bills, and the
|
||
// 1099 worksheet show what the party is called now.
|
||
p.name = n.trim().to_string();
|
||
let (name, vendor, customer) = (p.name.clone(), p.vendor, p.customer);
|
||
if let Some(v) = vendor.and_then(|v| self.vendors.get_mut(&v)) {
|
||
v.name = name.clone();
|
||
}
|
||
if let Some(c) = customer.and_then(|c| self.customers.get_mut(&c)) {
|
||
c.name = name;
|
||
}
|
||
}
|
||
DisplayName(v) => p.display_name = v,
|
||
Website(v) => p.website = v,
|
||
Address(v) => p.address = v,
|
||
Notes(v) => p.notes = v,
|
||
Archived(b) => p.archived = b,
|
||
Email(v) => p.email = v,
|
||
DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v,
|
||
DefaultTermsAsVendor(v) => p.default_terms_as_vendor = v,
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::update_party`].
|
||
pub fn check_update_party(
|
||
&self,
|
||
id: crate::PartyId,
|
||
field: &crate::PartyField,
|
||
) -> Result<(), LedgerError> {
|
||
use crate::PartyField::*;
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
let text = |f: &str, v: &Option<String>| match v {
|
||
Some(t) => crate::parties::check_text(f, t).map_err(LedgerError::InvalidParty),
|
||
None => Ok(()),
|
||
};
|
||
match field {
|
||
Name(n) => {
|
||
if n.trim().is_empty() {
|
||
return Err(LedgerError::InvalidParty("a party needs a name".into()));
|
||
}
|
||
crate::parties::check_text("name", n).map_err(LedgerError::InvalidParty)?;
|
||
if let Some(other) = self.party_by_name(n)
|
||
&& other.id != id
|
||
{
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"a party named {:?} already exists",
|
||
n.trim()
|
||
)));
|
||
}
|
||
}
|
||
DisplayName(v) => text("display_name", v)?,
|
||
Website(v) => text("website", v)?,
|
||
Address(v) => text("address", v)?,
|
||
Notes(v) => text("notes", v)?,
|
||
Archived(false) => {
|
||
if let Some(other) = self.party_by_name(&p.name)
|
||
&& other.id != id
|
||
{
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"another party is named {:?}; rename one first",
|
||
p.name
|
||
)));
|
||
}
|
||
}
|
||
Archived(true) => {}
|
||
Email(v) => {
|
||
if let Some(e) = v {
|
||
crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)?;
|
||
}
|
||
}
|
||
// Terms are typed and already canonical (`crate::Terms`), so
|
||
// there is nothing here a string could get wrong.
|
||
DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Give a party a role, or take one away.
|
||
///
|
||
/// ```text
|
||
/// SOURCE: crate::parties (INVARIANT 2).
|
||
/// SCENARIOS:
|
||
/// 1. IF the role is vendor or customer: SHOULD refuse (they follow
|
||
/// the records).
|
||
/// 2. IF taking away a 1099 role leaves W-9 facts with no 1099 role,
|
||
/// or taking away employee leaves W-4 facts: SHOULD refuse (clear
|
||
/// the facts first).
|
||
/// ```
|
||
pub fn set_party_role(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
role: crate::Role,
|
||
on: bool,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_party_role(id, role, on)?;
|
||
let p = self.parties.get_mut(&id).expect("checked");
|
||
if on {
|
||
p.roles.insert(role);
|
||
} else {
|
||
p.roles.remove(&role);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_party_role`].
|
||
pub fn check_set_party_role(
|
||
&self,
|
||
id: crate::PartyId,
|
||
role: crate::Role,
|
||
on: bool,
|
||
) -> Result<(), LedgerError> {
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
if matches!(role, crate::Role::Vendor | crate::Role::Customer) {
|
||
return Err(LedgerError::InvalidParty(
|
||
"vendor and customer roles follow their records".into(),
|
||
));
|
||
}
|
||
if !on {
|
||
let mut left = p.roles.clone();
|
||
left.remove(&role);
|
||
if p.w9.is_some() && !left.iter().any(|r| r.gets_1099()) {
|
||
return Err(LedgerError::InvalidParty(
|
||
"clear the W-9 facts before removing the last 1099 role".into(),
|
||
));
|
||
}
|
||
if p.w4.is_some() && role == crate::Role::Employee {
|
||
return Err(LedgerError::InvalidParty(
|
||
"clear the W-4 facts before removing the employee role".into(),
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Record (or clear) a party's W-9 facts.
|
||
///
|
||
/// ```text
|
||
/// SOURCE: crate::parties (INVARIANTS 1, 2).
|
||
/// SCENARIOS:
|
||
/// 1. IF the party has no 1099 role (contractor, landlord), or the
|
||
/// facts' form is wrong (W9Facts::check): SHOULD refuse.
|
||
/// ```
|
||
pub fn set_party_w9(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
facts: Option<crate::W9Facts>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_party_w9(id, facts.as_ref())?;
|
||
self.parties.get_mut(&id).expect("checked").w9 = facts;
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_party_w9`].
|
||
pub fn check_set_party_w9(
|
||
&self,
|
||
id: crate::PartyId,
|
||
facts: Option<&crate::W9Facts>,
|
||
) -> Result<(), LedgerError> {
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
if let Some(f) = facts {
|
||
if !p.roles.iter().any(|r| r.gets_1099()) {
|
||
return Err(LedgerError::InvalidParty(
|
||
"W-9 facts need a 1099 role (contractor or landlord)".into(),
|
||
));
|
||
}
|
||
f.check().map_err(LedgerError::InvalidParty)?;
|
||
if let Some(t) = &p.tin
|
||
&& t.last4 != f.tin_last4
|
||
{
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"the party's TIN on file ends in {}, the W-9 facts say {}: they must agree (record the W-9's TIN with it)",
|
||
t.last4, f.tin_last4
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Record (or clear) a party's taxpayer-ID facts: the TIN's type, last
|
||
/// four, and vault record, never the number.
|
||
///
|
||
/// ```text
|
||
/// SOURCE: crate::parties (INVARIANTS 1, 2, 2a); DECISION (owner,
|
||
/// 2026-10-15): every member's and payee's TIN in the vault.
|
||
/// SCENARIOS:
|
||
/// 1. Any role may have them (members need theirs for K-1s).
|
||
/// 2. IF the facts' form is wrong (TinFacts::check): SHOULD refuse.
|
||
/// 3. IF the party's W-9 facts name another last four: SHOULD refuse
|
||
/// (the two never disagree).
|
||
/// ```
|
||
pub fn set_party_tin(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
facts: Option<crate::TinFacts>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_party_tin(id, facts.as_ref())?;
|
||
self.parties.get_mut(&id).expect("checked").tin = facts;
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_party_tin`].
|
||
pub fn check_set_party_tin(
|
||
&self,
|
||
id: crate::PartyId,
|
||
facts: Option<&crate::TinFacts>,
|
||
) -> Result<(), LedgerError> {
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
if let Some(f) = facts {
|
||
f.check().map_err(LedgerError::InvalidParty)?;
|
||
if let Some(w) = &p.w9
|
||
&& w.tin_last4 != f.last4
|
||
{
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"the party's W-9 facts say the TIN ends in {}, not {}: a new TIN for a W-9 payee comes with its W-9 (books.set_w9)",
|
||
w.tin_last4, f.last4
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Name a party the creditor of a liability account the company owes
|
||
/// it itself on (`on`), or stop (`!on`): the card it issued, the loan
|
||
/// it made, what the members advanced (each member).
|
||
///
|
||
/// ```text
|
||
/// GOAL: the books know whose each card, loan and advance is, so a
|
||
/// credit to it never counts as paying that party (a fee on its
|
||
/// own card, interest on its own loan, a distribution still owed
|
||
/// to a member: a promise, not a payment).
|
||
/// SOURCE: crate::parties INVARIANT 5; rules.md B-35 (DECISION, owner,
|
||
/// 2026-10-20: "a liability credit never counts as paying that
|
||
/// liability's own creditor").
|
||
/// SCENARIOS:
|
||
/// 1. IF the party or the account is unknown: SHOULD refuse.
|
||
/// 2. IF the account isn't a liability, is a placeholder, or is
|
||
/// payables or a payroll withholding (owed to many, through their
|
||
/// own records): SHOULD refuse, changing nothing.
|
||
/// 3. Naming it twice, or stopping one never named, changes nothing.
|
||
/// 4. Several parties may be one account's creditors (both members,
|
||
/// for what the members advanced).
|
||
/// ```
|
||
pub fn set_party_creditor(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
account: AccountId,
|
||
on: bool,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_party_creditor(id, account, on)?;
|
||
let p = self.parties.get_mut(&id).expect("checked");
|
||
if on {
|
||
p.creditor_of.insert(account);
|
||
} else {
|
||
p.creditor_of.remove(&account);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_party_creditor`].
|
||
pub fn check_set_party_creditor(
|
||
&self,
|
||
id: crate::PartyId,
|
||
account: AccountId,
|
||
on: bool,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.parties.contains_key(&id) {
|
||
return Err(LedgerError::UnknownParty(id));
|
||
}
|
||
let a = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
if !on {
|
||
return Ok(());
|
||
}
|
||
if a.account_type != AccountType::Liability {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"account {} isn't a liability: a party is the creditor only of what the company owes it (a card, a loan, what the members advanced)",
|
||
a.code
|
||
)));
|
||
}
|
||
if a.placeholder {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"account {} is a placeholder: name the creditor of the account under it that is posted to",
|
||
a.code
|
||
)));
|
||
}
|
||
let many = PAYROLL[2..]
|
||
.iter()
|
||
.chain([&PAYABLES])
|
||
.any(|c| self.role_account(c) == Some(account));
|
||
if many {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"account {} is owed to many parties through their own records (bills, payroll): it has no one creditor",
|
||
a.code
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The parties named the creditor of `account`, by id
|
||
/// ([`Self::set_party_creditor`]).
|
||
pub fn creditors_of(&self, account: AccountId) -> Vec<crate::PartyId> {
|
||
self.parties
|
||
.values()
|
||
.filter(|p| p.creditor_of.contains(&account))
|
||
.map(|p| p.id)
|
||
.collect()
|
||
}
|
||
|
||
/// Record (or clear) a party's W-4 facts.
|
||
pub fn set_party_w4(
|
||
&mut self,
|
||
id: crate::PartyId,
|
||
facts: Option<crate::W4Facts>,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_set_party_w4(id, facts.as_ref())?;
|
||
self.parties.get_mut(&id).expect("checked").w4 = facts;
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_party_w4`].
|
||
pub fn check_set_party_w4(
|
||
&self,
|
||
id: crate::PartyId,
|
||
facts: Option<&crate::W4Facts>,
|
||
) -> Result<(), LedgerError> {
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
if let Some(f) = facts {
|
||
if !p.roles.contains(&crate::Role::Employee) {
|
||
return Err(LedgerError::InvalidParty(
|
||
"W-4 facts need the employee role".into(),
|
||
));
|
||
}
|
||
f.check().map_err(LedgerError::InvalidParty)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Tag an entry with the party it was with, or clear the tag: one
|
||
/// party with all of it ([`Self::set_entry_parties`] with one derived
|
||
/// share). A party with a vendor record also tags the entry's vendor
|
||
/// (for 1099s), as [`Self::set_entry_vendor`] does the other way round.
|
||
/// Metadata: no amount changes, so posted entries may be tagged.
|
||
pub fn set_entry_party(
|
||
&mut self,
|
||
entry: EntryId,
|
||
party: Option<crate::PartyId>,
|
||
) -> Result<(), LedgerError> {
|
||
let shares: Vec<crate::PartyShare> =
|
||
party.into_iter().map(crate::PartyShare::whole).collect();
|
||
self.set_entry_parties(entry, &shares)
|
||
}
|
||
|
||
/// Name the parties an entry was with, each with their share, or
|
||
/// clear them (an empty list). Metadata: no amount changes, so posted
|
||
/// entries may be tagged.
|
||
///
|
||
/// ```text
|
||
/// GOAL: one entry with several counterparties (both members, two
|
||
/// contractors), apportioned exactly.
|
||
/// SOURCE: crate::shares (INVARIANTS 1–5); DECISION (owner,
|
||
/// 2026-10-15).
|
||
/// INVARIANTS:
|
||
/// 1. The shares are checked as crate::shares::check_shares says, and
|
||
/// at most one party has a vendor record; refused otherwise,
|
||
/// changing nothing. Whether they resolve against the entry's lines
|
||
/// depends on settings (cash accounts, members) core doesn't hold:
|
||
/// callers check crate::shares::resolve before journaling.
|
||
/// 2. The entry's vendor tag follows the one party with a vendor
|
||
/// record; with none, it is cleared.
|
||
/// 3. Naming any party clears a no-party mark.
|
||
/// 4. entry_party(entry) is Some only for exactly one party.
|
||
/// 5. An entry and its reversal (and theirs: Self::reversal_chain)
|
||
/// name the same parties, shares, and vendor tag: tagging any one
|
||
/// of them tags the whole chain, so each party's part of an entry
|
||
/// and its reversal still nets to zero (crate::shares INVARIANT 6;
|
||
/// form1099 SCENARIO 6), however it is re-apportioned later.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry or a party is unknown, a party is named twice, or
|
||
/// two parties have vendor records: SHOULD refuse.
|
||
/// 2. Both members, no shares: two derived shares.
|
||
/// 3. A reversed $1,000.00 payment named 600/400, then re-apportioned
|
||
/// 700/300: the reversal is 700/300 too, and the pair nets to 0.00
|
||
/// for each party.
|
||
/// ```
|
||
pub fn set_entry_parties(
|
||
&mut self,
|
||
entry: EntryId,
|
||
shares: &[crate::PartyShare],
|
||
) -> Result<(), LedgerError> {
|
||
let vendor = self.check_set_entry_parties(entry, shares)?;
|
||
self.apply_entry_parties(entry, shares, vendor);
|
||
Ok(())
|
||
}
|
||
|
||
/// [`Self::set_entry_parties`] with the checks every journaled
|
||
/// `entry.parties_set` passed when it was written
|
||
/// (crate::shares::check_shares_journaled: no upper bound on an
|
||
/// amount), for replay only (rules.md B-28). Never for new work.
|
||
pub fn replay_set_entry_parties(
|
||
&mut self,
|
||
entry: EntryId,
|
||
shares: &[crate::PartyShare],
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
let vendor = self.party_shares_vendor(shares, false)?;
|
||
self.apply_entry_parties(entry, shares, vendor);
|
||
Ok(())
|
||
}
|
||
|
||
/// Record checked shares on the entry's reversal chain
|
||
/// ([`Self::set_entry_parties`] INVARIANTS 2-5).
|
||
fn apply_entry_parties(
|
||
&mut self,
|
||
entry: EntryId,
|
||
shares: &[crate::PartyShare],
|
||
vendor: Option<VendorId>,
|
||
) {
|
||
for e in self.reversal_chain(entry) {
|
||
if shares.is_empty() {
|
||
self.entry_parties.remove(&e);
|
||
self.entry_vendors.remove(&e);
|
||
continue;
|
||
}
|
||
self.entry_parties.insert(e, shares.to_vec());
|
||
self.entry_no_party.remove(&e);
|
||
match vendor {
|
||
Some(v) => {
|
||
self.entry_vendors.insert(e, v);
|
||
}
|
||
None => {
|
||
self.entry_vendors.remove(&e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The validation behind [`Self::set_entry_parties`]: returns the
|
||
/// vendor record the entry's vendor tag follows, if any.
|
||
pub fn check_set_entry_parties(
|
||
&self,
|
||
entry: EntryId,
|
||
shares: &[crate::PartyShare],
|
||
) -> Result<Option<VendorId>, LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
self.check_party_shares(shares)
|
||
}
|
||
|
||
/// The checks [`Self::check_set_entry_parties`] makes of the shares
|
||
/// themselves, for an entry not yet created: returns the vendor record
|
||
/// the vendor tag would follow, if any.
|
||
pub fn check_party_shares(
|
||
&self,
|
||
shares: &[crate::PartyShare],
|
||
) -> Result<Option<VendorId>, LedgerError> {
|
||
self.party_shares_vendor(shares, true)
|
||
}
|
||
|
||
/// [`Self::check_party_shares`], with crate::shares::check_shares when
|
||
/// `strict` (new work) or check_shares_journaled (replay).
|
||
fn party_shares_vendor(
|
||
&self,
|
||
shares: &[crate::PartyShare],
|
||
strict: bool,
|
||
) -> Result<Option<VendorId>, LedgerError> {
|
||
if let Some(s) = shares.iter().find(|s| !self.parties.contains_key(&s.party)) {
|
||
return Err(LedgerError::UnknownParty(s.party));
|
||
}
|
||
let known = |p| self.parties.contains_key(&p);
|
||
if strict {
|
||
crate::shares::check_shares(shares, known)
|
||
} else {
|
||
crate::shares::check_shares_journaled(shares, known)
|
||
}
|
||
.map_err(LedgerError::InvalidParty)?;
|
||
let vendors: Vec<(crate::PartyId, VendorId)> = shares
|
||
.iter()
|
||
.filter_map(|s| self.parties[&s.party].vendor.map(|v| (s.party, v)))
|
||
.collect();
|
||
if let [_, _, ..] = vendors.as_slice() {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"parties {} have vendor records; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry",
|
||
vendors
|
||
.iter()
|
||
.map(|(p, _)| p.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(" and ")
|
||
)));
|
||
}
|
||
Ok(vendors.first().map(|(_, v)| *v))
|
||
}
|
||
|
||
/// Mark an entry as having no counterparty, with why (a transfer
|
||
/// between the company's own accounts, an accrual, a correction), so a
|
||
/// books policy that requires parties can tell "none" from "not yet".
|
||
///
|
||
/// ```text
|
||
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
|
||
/// (owner, 2026-10-15).
|
||
/// INVARIANTS:
|
||
/// 1. An entry and its reversal (and theirs: Self::reversal_chain)
|
||
/// carry the same mark, as they name the same parties
|
||
/// (set_entry_parties INVARIANT 5): marking any one marks the
|
||
/// chain, and reverse_entry copies the mark to the reversal.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry is unknown, already names a party, or the reason
|
||
/// is blank or fails the party text rules: SHOULD refuse.
|
||
/// 2. Tagging the entry with a party later clears the mark
|
||
/// (set_entry_parties, set_entry_party, set_entry_vendor), on the
|
||
/// whole chain.
|
||
/// 3. A transfer between the company's own accounts marked, then
|
||
/// reversed: the reversal is marked with the same reason.
|
||
/// ```
|
||
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
|
||
self.check_set_entry_no_party(entry, reason)?;
|
||
for e in self.reversal_chain(entry) {
|
||
self.entry_no_party.insert(e, reason.trim().to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The validation behind [`Self::set_entry_no_party`].
|
||
pub fn check_set_entry_no_party(
|
||
&self,
|
||
entry: EntryId,
|
||
reason: &str,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
if let Some(e) = self
|
||
.reversal_chain(entry)
|
||
.into_iter()
|
||
.find(|e| self.entry_parties.contains_key(e))
|
||
{
|
||
return Err(LedgerError::InvalidParty(if e == entry {
|
||
format!("entry {entry} names a party; clear it first")
|
||
} else {
|
||
format!("entry {entry}'s reversal chain entry {e} names a party; clear it first")
|
||
}));
|
||
}
|
||
if reason.trim().is_empty() {
|
||
return Err(LedgerError::InvalidParty(
|
||
"say why the entry has no counterparty".into(),
|
||
));
|
||
}
|
||
crate::parties::check_text("reason", reason).map_err(LedgerError::InvalidParty)
|
||
}
|
||
|
||
/// Why an entry has no counterparty, when it was marked so.
|
||
pub fn entry_no_party(&self, entry: EntryId) -> Option<&str> {
|
||
self.entry_no_party.get(&entry).map(String::as_str)
|
||
}
|
||
|
||
/// The vendor an entry is tagged with.
|
||
pub fn entry_vendor(&self, entry: EntryId) -> Option<VendorId> {
|
||
self.entry_vendors.get(&entry).copied()
|
||
}
|
||
|
||
/// All customers, in id order.
|
||
pub fn list_customers(&self) -> Vec<&Customer> {
|
||
let mut ids: Vec<CustomerId> = self.customers.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.customers.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// All vendors, in id order.
|
||
pub fn list_vendors(&self) -> Vec<&Vendor> {
|
||
let mut ids: Vec<VendorId> = self.vendors.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.vendors.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// Check the control accounts a command will post to by code, before
|
||
/// anything changes: each is either missing (it will be created) or a
|
||
/// postable account of the right type.
|
||
///
|
||
/// ```text
|
||
/// GOAL: never post a control role (receivables, payables, revenue,
|
||
/// payroll, retained earnings) to an account of the wrong kind.
|
||
/// SOURCE: docs/spec/rules.md B-4 (the elements); DECISION (owner,
|
||
/// 2026-10-13): refuse with a reason, never force the chart's
|
||
/// numbering.
|
||
/// INVARIANTS:
|
||
/// 1. MUST NOT mutate anything.
|
||
/// 2. NEVER accepts an existing account of another type, or a
|
||
/// placeholder, for a control role.
|
||
/// SCENARIOS:
|
||
/// 1. IF the code is free: SHOULD accept (the command creates it).
|
||
/// 2. IF the code holds a postable account of the right type: SHOULD
|
||
/// accept.
|
||
/// 3. IF it holds another type or a placeholder:
|
||
/// - SHOULD refuse with ControlAccountMismatch, naming the code,
|
||
/// the account, its type, the role, and the type needed
|
||
/// - NEVER post to it, and NEVER rename or renumber it
|
||
/// BECAUSE imported charts use their own numbering; the human
|
||
/// decides.
|
||
/// ```
|
||
fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> {
|
||
for c in controls {
|
||
// An assigned account was checked when it was assigned, and
|
||
// an account's type never changes.
|
||
if self.roles.contains_key(c.key) {
|
||
continue;
|
||
}
|
||
if let Some(a) = self.chart.find_by_code(c.code)
|
||
&& (a.account_type != c.account_type || a.placeholder)
|
||
{
|
||
return Err(LedgerError::ControlAccountMismatch {
|
||
code: c.code.to_string(),
|
||
name: a.name.clone(),
|
||
role: c.role.to_string(),
|
||
expected: c.account_type,
|
||
found: a.account_type,
|
||
placeholder: a.placeholder,
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Ensure a control account exists, creating it if missing. Callers
|
||
/// run [`Self::check_controls`] first, so an existing account here is
|
||
/// the right type.
|
||
fn ensure_control(&mut self, c: &ControlAccount) -> AccountId {
|
||
match self.roles.get(c.key) {
|
||
Some(&id) => id,
|
||
None => self.ensure_account(c),
|
||
}
|
||
}
|
||
|
||
/// Ensure a control role's account exists at its code, creating it
|
||
/// (with the role's name and type: [`Self::add_control_account`]) if
|
||
/// missing. Returns the account id.
|
||
fn ensure_account(&mut self, c: &ControlAccount) -> AccountId {
|
||
if let Some(a) = self.chart.find_by_code(c.code) {
|
||
return a.id;
|
||
}
|
||
self.add_control_account(c).expect("fresh code cannot fail")
|
||
}
|
||
|
||
/// A document's revenue, expense or asset side on its draft `entry`:
|
||
/// one line on each part's account, or on `default_account` when the
|
||
/// part names none ([`arap::document_parts`]), each tagged with its
|
||
/// project (a project the books don't have: UnknownClass, and the
|
||
/// caller's `atomically` leaves the books unchanged). Every account is
|
||
/// checked for the document's kind first
|
||
/// ([`Self::check_document_line_account`]).
|
||
fn add_document_parts(
|
||
&mut self,
|
||
entry: EntryId,
|
||
default_account: AccountId,
|
||
side: Side,
|
||
kind: arap::DocumentKind,
|
||
parts: &[(arap::DocumentPartKey, Decimal)],
|
||
) -> Result<(), LedgerError> {
|
||
for &((account, class), amount) in parts {
|
||
let account = account.unwrap_or(default_account);
|
||
self.check_document_line_account(kind, account)?;
|
||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||
if class.is_some() {
|
||
let last = self
|
||
.get_entry(entry)
|
||
.map_or(0, |e| e.lines().len())
|
||
.saturating_sub(1);
|
||
self.set_line_class(entry, last, class)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A document line's account must be one its kind can post to (plan
|
||
/// P4b S4.1, A-10): an invoice line takes a revenue account, or a
|
||
/// liability holding no role (2400 Deferred Revenue, a contract
|
||
/// liability); a bill line takes an expense account, or an asset that
|
||
/// is not cash and holds no role (1500 Equipment, 1300 Prepaid).
|
||
/// Placeholders and the receivables and payables control accounts are
|
||
/// refused. Checked when a line is added and when the entry posts.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a document's lines go where its kind can put them, never to a
|
||
/// control, a cash account or a header.
|
||
/// SOURCE: docs/spec/rules.md B-39; plan P4b S4.1 ("Line accounts"),
|
||
/// A-10 (every bill line names its account);
|
||
/// ASC 606-10-45-2 (deferred revenue is a liability).
|
||
/// INVARIANTS:
|
||
/// 1. An invoice line: a revenue account, or a liability that holds
|
||
/// no role.
|
||
/// 2. A bill line: an expense account, or an asset that is not cash
|
||
/// (`Ledger::account_is_cash`) and holds no role.
|
||
/// 3. A placeholder, or an account that is a document control
|
||
/// (receivables or payables), is refused for both.
|
||
/// 4. A refusal names the account, the kind and the rule.
|
||
/// ```
|
||
pub fn check_document_line_account(
|
||
&self,
|
||
kind: arap::DocumentKind,
|
||
account: AccountId,
|
||
) -> Result<(), LedgerError> {
|
||
self.check_line_account(account)?;
|
||
let a = self.chart.get(account).expect("checked above");
|
||
let kind_name = match kind {
|
||
arap::DocumentKind::Invoice => "an invoice",
|
||
arap::DocumentKind::Bill => "a bill",
|
||
};
|
||
let refuse = |why: &str| {
|
||
Err(LedgerError::DocumentLineAccount {
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
kind: kind_name,
|
||
why: why.to_string(),
|
||
})
|
||
};
|
||
if self.control_role_of(account).is_some() {
|
||
return refuse(
|
||
"it is a receivables or payables control account; its balance is its documents'",
|
||
);
|
||
}
|
||
match kind {
|
||
arap::DocumentKind::Invoice => match a.account_type {
|
||
AccountType::Revenue => Ok(()),
|
||
AccountType::Liability if !self.holds_any_role(account) => Ok(()),
|
||
_ => refuse(
|
||
"an invoice line takes a revenue account, or a liability holding no role (deferred revenue)",
|
||
),
|
||
},
|
||
arap::DocumentKind::Bill => {
|
||
if a.account_type == AccountType::Expense {
|
||
return Ok(());
|
||
}
|
||
if a.account_type == AccountType::Asset
|
||
&& !self.account_is_cash(account)
|
||
&& !self.holds_any_role(account)
|
||
{
|
||
return Ok(());
|
||
}
|
||
refuse(
|
||
"a bill line takes an expense account, or an asset that is not cash and holds no role (equipment, prepaid)",
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether `account` is one of the books' cash accounts: the codes in
|
||
/// `books.cash_accounts` when set, else (as `ledger-store`'s
|
||
/// reconciliation reads them) the asset accounts whose names say they
|
||
/// hold cash.
|
||
fn account_is_cash(&self, account: AccountId) -> bool {
|
||
let Some(a) = self.chart.get(account) else {
|
||
return false;
|
||
};
|
||
let configured = self.settings.cash_accounts();
|
||
if !configured.is_empty() {
|
||
return configured.iter().any(|c| c == &a.code);
|
||
}
|
||
const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"];
|
||
let name = a.name.to_lowercase();
|
||
a.account_type == AccountType::Asset
|
||
&& !a.placeholder
|
||
&& CASH_WORDS.iter().any(|w| name.contains(w))
|
||
}
|
||
|
||
/// Whether `account` fills any of the books' control roles (assigned,
|
||
/// or the account at the role's code and type).
|
||
fn holds_any_role(&self, account: AccountId) -> bool {
|
||
control_accounts().any(|c| self.holds_role(c, account))
|
||
}
|
||
|
||
/// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable,
|
||
/// Cr each line's revenue account (the revenue role, `4000`, when a
|
||
/// line names none). The invoice date must not fall in a closed period.
|
||
///
|
||
/// ```text
|
||
/// GOAL: bill a customer and recognize the revenue (accrual basis).
|
||
/// SOURCE: IRS Pub. 538, accrual method (docs/spec/sources/irs-periods-and-methods.md:
|
||
/// "report income in the year it is earned"); ASC 606-10-25-23;
|
||
/// control accounts: Ledger::check_controls; rules.md B-38, B-39.
|
||
/// INVARIANTS:
|
||
/// 1. MUST post one balanced entry: Dr 1200 receivables, Cr the lines'
|
||
/// accounts, for the sum of the lines, dated the invoice date: one
|
||
/// line per (account, project) part the lines name, the parts
|
||
/// naming neither as one (arap::document_parts), each revenue line
|
||
/// tagged with its project; the receivable line never (rules.md
|
||
/// B-37 item 8).
|
||
/// 2. The receivable line MUST carry the invoice (Origin), in the
|
||
/// step that posts it: the invoice starts Open, owing its total.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the customer is unknown, a line amount isn't positive, the due
|
||
/// date is before the invoice date, a line names a project the
|
||
/// books don't have, or the date is in a closed period: SHOULD
|
||
/// refuse.
|
||
/// ```
|
||
pub fn create_invoice(
|
||
&mut self,
|
||
customer_id: CustomerId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
) -> Result<InvoiceId, LedgerError> {
|
||
self.create_invoice_with(
|
||
customer_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
arap::InvoiceFields::default(),
|
||
)
|
||
}
|
||
|
||
/// [`Self::create_invoice`] with the document's new fields (number,
|
||
/// terms, snapshots, references: plan P4b S4.1). The fields are stored
|
||
/// on the invoice at issue and never change. All `None` is exactly
|
||
/// [`Self::create_invoice`].
|
||
pub fn create_invoice_with(
|
||
&mut self,
|
||
customer_id: CustomerId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
fields: arap::InvoiceFields,
|
||
) -> Result<InvoiceId, LedgerError> {
|
||
self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines, fields))
|
||
}
|
||
|
||
/// The steps of [`Self::create_invoice`], run inside [`Self::atomically`].
|
||
fn create_invoice_steps(
|
||
&mut self,
|
||
customer_id: CustomerId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
fields: arap::InvoiceFields,
|
||
) -> Result<InvoiceId, LedgerError> {
|
||
if !self.customers.contains_key(&customer_id) {
|
||
return Err(LedgerError::UnknownCustomer(customer_id));
|
||
}
|
||
arap::validate_lines(&lines)?;
|
||
arap::validate_due_date(date, due_date)?;
|
||
if let Some(number) = &fields.number
|
||
&& self
|
||
.invoices
|
||
.values()
|
||
.any(|i| i.number.as_deref() == Some(number.as_str()))
|
||
{
|
||
return Err(LedgerError::DuplicateInvoiceNumber(number.clone()));
|
||
}
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
let total = arap::document_total(&lines, "the invoice")?;
|
||
let parts = arap::document_parts(&lines, "the invoice")?;
|
||
self.check_controls(&[RECEIVABLES, REVENUE])?;
|
||
let ar = self.ensure_control(&RECEIVABLES);
|
||
let revenue = self.ensure_control(&REVENUE);
|
||
let id = self.next_invoice_id;
|
||
let entry = self.create_entry(date, &format!("Invoice {id}"));
|
||
self.add_tagged_line(
|
||
entry,
|
||
ar,
|
||
Side::Debit,
|
||
Money::new(total, self.base_currency),
|
||
arap::DocTag {
|
||
doc: arap::DocRef::Invoice(id),
|
||
movement: arap::Movement::Origin,
|
||
},
|
||
)?;
|
||
self.add_document_parts(
|
||
entry,
|
||
revenue,
|
||
Side::Credit,
|
||
arap::DocumentKind::Invoice,
|
||
&parts,
|
||
)?;
|
||
self.post_entry(entry)?;
|
||
self.next_invoice_id += 1;
|
||
self.invoices.insert(
|
||
id,
|
||
Invoice {
|
||
id,
|
||
customer_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
amount_paid: Decimal::ZERO,
|
||
owed: Decimal::ZERO,
|
||
status: DocumentStatus::Open,
|
||
entry_id: entry,
|
||
number: fields.number,
|
||
terms: fields.terms,
|
||
bill_to: fields.bill_to,
|
||
seller: fields.seller,
|
||
customer_ref: fields.customer_ref,
|
||
memo: fields.memo,
|
||
sends: Vec::new(),
|
||
},
|
||
);
|
||
self.tag_customer(entry, customer_id);
|
||
self.refresh_doc(arap::DocRef::Invoice(id));
|
||
Ok(id)
|
||
}
|
||
|
||
/// Create a vendor bill. Auto-posts: Dr each line's expense or asset
|
||
/// account (general expenses, `6999`, when a line names none), Cr
|
||
/// `2000` Accounts Payable. The bill date must not fall in a closed
|
||
/// period.
|
||
///
|
||
/// ```text
|
||
/// GOAL: record a vendor's bill as an expense or asset owed (accrual
|
||
/// basis).
|
||
/// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year
|
||
/// incurred"); control accounts: Ledger::check_controls;
|
||
/// rules.md B-38, B-39 (plan P4b S4.1, A-10).
|
||
/// INVARIANTS:
|
||
/// 1. MUST post one balanced entry: Dr the lines' accounts, Cr 2000
|
||
/// payables, for the sum of the lines, dated the bill date, tagged
|
||
/// with the vendor (1099s): one line per (account, project) part
|
||
/// the lines name, the parts naming neither as one
|
||
/// (arap::document_parts), each tagged with its project; the
|
||
/// payable line never (rules.md B-37 item 8).
|
||
/// 2. The payable line MUST carry the bill (Origin), in the step that
|
||
/// posts it.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the vendor is unknown, a line amount isn't positive, the due date
|
||
/// is before the bill date, a line names a project the books don't
|
||
/// have, or the date is in a closed period: SHOULD refuse.
|
||
/// ```
|
||
pub fn create_bill(
|
||
&mut self,
|
||
vendor_id: VendorId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
) -> Result<BillId, LedgerError> {
|
||
self.create_bill_with(
|
||
vendor_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
arap::BillFields::default(),
|
||
)
|
||
}
|
||
|
||
/// [`Self::create_bill`] with the bill's new fields (the vendor's
|
||
/// invoice number, terms, receipt date, memo: plan P4b S4.1). The
|
||
/// fields are stored on the bill at issue and never change. All `None`
|
||
/// is exactly [`Self::create_bill`].
|
||
pub fn create_bill_with(
|
||
&mut self,
|
||
vendor_id: VendorId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
fields: arap::BillFields,
|
||
) -> Result<BillId, LedgerError> {
|
||
self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines, fields))
|
||
}
|
||
|
||
/// The steps of [`Self::create_bill`], run inside [`Self::atomically`].
|
||
fn create_bill_steps(
|
||
&mut self,
|
||
vendor_id: VendorId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
fields: arap::BillFields,
|
||
) -> Result<BillId, LedgerError> {
|
||
if !self.vendors.contains_key(&vendor_id) {
|
||
return Err(LedgerError::UnknownVendor(vendor_id));
|
||
}
|
||
arap::validate_lines(&lines)?;
|
||
arap::validate_due_date(date, due_date)?;
|
||
if let Some(received_on) = fields.received_on {
|
||
crate::calendar::check(received_on)?;
|
||
}
|
||
if let Some(vendor_ref) = fields.vendor_ref.as_deref() {
|
||
self.check_vendor_ref(vendor_id, vendor_ref, fields.duplicate_reason.as_deref())?;
|
||
}
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
let total = arap::document_total(&lines, "the bill")?;
|
||
let parts = arap::document_parts(&lines, "the bill")?;
|
||
self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?;
|
||
let expense = self.ensure_control(&GENERAL_EXPENSES);
|
||
let ap = self.ensure_control(&PAYABLES);
|
||
let id = self.next_bill_id;
|
||
let entry = self.create_entry(date, &format!("Bill {id}"));
|
||
self.add_document_parts(
|
||
entry,
|
||
expense,
|
||
Side::Debit,
|
||
arap::DocumentKind::Bill,
|
||
&parts,
|
||
)?;
|
||
self.add_tagged_line(
|
||
entry,
|
||
ap,
|
||
Side::Credit,
|
||
Money::new(total, self.base_currency),
|
||
arap::DocTag {
|
||
doc: arap::DocRef::Bill(id),
|
||
movement: arap::Movement::Origin,
|
||
},
|
||
)?;
|
||
self.post_entry(entry)?;
|
||
self.next_bill_id += 1;
|
||
self.bills.insert(
|
||
id,
|
||
Bill {
|
||
id,
|
||
vendor_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
amount_paid: Decimal::ZERO,
|
||
owed: Decimal::ZERO,
|
||
status: DocumentStatus::Open,
|
||
entry_id: entry,
|
||
vendor_ref: fields.vendor_ref,
|
||
received_on: fields.received_on,
|
||
terms: fields.terms,
|
||
memo: fields.memo,
|
||
},
|
||
);
|
||
self.tag_vendor(entry, vendor_id);
|
||
self.refresh_doc(arap::DocRef::Bill(id));
|
||
Ok(id)
|
||
}
|
||
|
||
/// The number the next invoice is issued under (A-2, B-40): `prefix`,
|
||
/// then `first_number` plus the count of numbered invoices issued so
|
||
/// far, zero-padded to four digits and wider past 9,999 (never
|
||
/// truncated). Stored at issue, never recomputed; a void keeps its
|
||
/// number, so the series never reuses one; a legacy invoice
|
||
/// (`number: None`) is not counted.
|
||
pub fn next_invoice_number(&self, prefix: &str, first_number: u64) -> String {
|
||
let issued = self
|
||
.invoices
|
||
.values()
|
||
.filter(|i| i.number.is_some())
|
||
.count() as u64;
|
||
format!("{prefix}{:04}", first_number.saturating_add(issued))
|
||
}
|
||
|
||
/// Refuse a vendor invoice number that already names a standing bill
|
||
/// of the same vendor (B-40), unless `duplicate_reason` says why it is
|
||
/// entered again. Case, spaces and hyphens are ignored: "ACME-123",
|
||
/// "acme 123" and "Acme123" are the same number.
|
||
pub fn check_vendor_ref(
|
||
&self,
|
||
vendor: VendorId,
|
||
vendor_ref: &str,
|
||
duplicate_reason: Option<&str>,
|
||
) -> Result<(), LedgerError> {
|
||
if duplicate_reason.is_some_and(|r| !r.trim().is_empty()) {
|
||
return Ok(());
|
||
}
|
||
let key = normalize_vendor_ref(vendor_ref);
|
||
if key.is_empty() {
|
||
return Ok(());
|
||
}
|
||
for bill in self.bills.values() {
|
||
if bill.vendor_id != vendor || !self.in_effect(bill.entry_id) {
|
||
continue;
|
||
}
|
||
if let Some(existing) = &bill.vendor_ref
|
||
&& normalize_vendor_ref(existing) == key
|
||
{
|
||
return Err(LedgerError::DuplicateVendorRef {
|
||
vendor,
|
||
vendor_ref: vendor_ref.to_string(),
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Look up an invoice.
|
||
pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> {
|
||
self.invoices.get(&id)
|
||
}
|
||
|
||
/// Look up a bill.
|
||
pub fn get_bill(&self, id: BillId) -> Option<&Bill> {
|
||
self.bills.get(&id)
|
||
}
|
||
|
||
/// Every invoice, by id.
|
||
pub fn invoices(&self) -> Vec<&Invoice> {
|
||
let mut v: Vec<&Invoice> = self.invoices.values().collect();
|
||
v.sort_by_key(|i| i.id);
|
||
v
|
||
}
|
||
|
||
/// Every bill, by id.
|
||
pub fn bills(&self) -> Vec<&Bill> {
|
||
let mut v: Vec<&Bill> = self.bills.values().collect();
|
||
v.sort_by_key(|b| b.id);
|
||
v
|
||
}
|
||
|
||
/// Every document: the invoices by id, then the bills by id.
|
||
pub fn documents(&self) -> Vec<arap::DocRef> {
|
||
self.invoices()
|
||
.into_iter()
|
||
.map(|i| arap::DocRef::Invoice(i.id))
|
||
.chain(self.bills().into_iter().map(|b| arap::DocRef::Bill(b.id)))
|
||
.collect()
|
||
}
|
||
|
||
/// The document a ledger line belongs to, if it carries one (rules.md
|
||
/// B-38): `(entry, line index)`.
|
||
pub fn line_doc(&self, entry: EntryId, line: usize) -> Option<arap::DocTag> {
|
||
self.line_docs.get(&(entry, line)).copied()
|
||
}
|
||
|
||
/// A document's date, due date, own entry and total; refused for an
|
||
/// unknown document (UnknownInvoice, UnknownBill) or a total past
|
||
/// exact arithmetic.
|
||
fn doc_header(
|
||
&self,
|
||
doc: arap::DocRef,
|
||
) -> Result<(NaiveDate, NaiveDate, EntryId, Decimal), LedgerError> {
|
||
match doc {
|
||
arap::DocRef::Invoice(id) => {
|
||
let i = self
|
||
.invoices
|
||
.get(&id)
|
||
.ok_or(LedgerError::UnknownInvoice(id))?;
|
||
Ok((i.date, i.due_date, i.entry_id, i.total()?))
|
||
}
|
||
arap::DocRef::Bill(id) => {
|
||
let b = self.bills.get(&id).ok_or(LedgerError::UnknownBill(id))?;
|
||
Ok((b.date, b.due_date, b.entry_id, b.total()?))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every posted ledger line tagged with `doc`, in date order (posting
|
||
/// order within a day): its origin, payments, and the mirrors of any
|
||
/// of them reversed.
|
||
pub fn doc_lines(&self, doc: arap::DocRef) -> Vec<arap::DocLine> {
|
||
let Some(tagged) = self.doc_index.get(&doc) else {
|
||
return Vec::new();
|
||
};
|
||
let mut out: Vec<arap::DocLine> = tagged
|
||
.iter()
|
||
.filter_map(|&(entry, line)| {
|
||
let e = self.entries.get(&entry)?;
|
||
if e.status() != EntryStatus::Posted {
|
||
return None;
|
||
}
|
||
let l = e.lines().get(line)?;
|
||
Some(arap::DocLine {
|
||
entry,
|
||
line,
|
||
date: e.date(),
|
||
account: l.account,
|
||
movement: self.line_docs.get(&(entry, line))?.movement,
|
||
amount: doc.counted(l.side, l.amount.amount),
|
||
reversal: e.reversal_of().is_some(),
|
||
})
|
||
})
|
||
.collect();
|
||
out.sort_by_key(|l| (l.date, self.order_pos.get(&l.entry).copied(), l.line));
|
||
out
|
||
}
|
||
|
||
/// What a document is as of a day, read from the ledger lines tagged
|
||
/// with it (rules.md B-38; plan P4b.3, "Derived figures"). `None` for
|
||
/// a document dated after `as_of` (it wasn't issued yet).
|
||
///
|
||
/// ```text
|
||
/// GOAL: every figure about a document (paid, outstanding, status,
|
||
/// overdue) is read from its tagged lines, so a reversal, a void
|
||
/// or a bounced payment is right by construction.
|
||
/// SOURCE: docs/spec/rules.md B-38 (INVARIANTS 1-4), B-9, B-10, B-18;
|
||
/// ASC 310-10 (receivables), ASC 405-20-40-1 (payables).
|
||
/// INVARIANTS:
|
||
/// 1. outstanding = the signed sum of every posted line tagged with
|
||
/// it dated on or before `as_of` (a debit to receivables counts
|
||
/// plus for an invoice; a credit to payables for a bill): the
|
||
/// very lines the control account sums.
|
||
/// 2. paid (discounted, written off) = minus the same sum over its
|
||
/// Payment (Discount, WriteOff) lines: a reversed payment counts
|
||
/// from its reversal's date, never after.
|
||
/// 3. status = arap::doc_status(its own entry stands on `as_of`, its
|
||
/// own entry's control line carries it, ...).
|
||
/// 4. overdue_days > 0 only while something is owed past the due date.
|
||
/// 5. A pure function of (the books, as_of); NEVER panics:
|
||
/// TooLargeToCompute past exact arithmetic.
|
||
/// SCENARIOS:
|
||
/// 1. IF an invoice of 100 was paid 100 and the payment reversed:
|
||
/// SHOULD be Open, owing 100 (scenario 1 of B-38).
|
||
/// 2. IF its own entry was reversed with nothing paid: SHOULD be Void,
|
||
/// owing nothing.
|
||
/// 3. IF asked between a payment and its reversal: SHOULD be Paid.
|
||
/// ```
|
||
pub fn doc_state(
|
||
&self,
|
||
doc: arap::DocRef,
|
||
as_of: NaiveDate,
|
||
) -> Result<Option<arap::DocState>, LedgerError> {
|
||
let (date, due_date, entry, total) = self.doc_header(doc)?;
|
||
if date > as_of {
|
||
return Ok(None);
|
||
}
|
||
let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures"));
|
||
let add = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
|
||
let (mut outstanding, mut paid, mut discounted, mut written_off) =
|
||
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||
let lines = self.doc_lines(doc);
|
||
let origin_tagged = lines
|
||
.iter()
|
||
.any(|l| l.entry == entry && l.movement == arap::Movement::Origin);
|
||
for l in lines.into_iter().filter(|l| l.date <= as_of) {
|
||
outstanding = add(outstanding, l.amount)?;
|
||
match l.movement {
|
||
arap::Movement::Payment => paid = add(paid, -l.amount)?,
|
||
arap::Movement::Discount => discounted = add(discounted, -l.amount)?,
|
||
arap::Movement::WriteOff => written_off = add(written_off, -l.amount)?,
|
||
arap::Movement::Origin | arap::Movement::Adopted | arap::Movement::RoleMove => {}
|
||
}
|
||
}
|
||
let status = arap::doc_status(
|
||
self.stands_on(entry, as_of),
|
||
origin_tagged,
|
||
total,
|
||
outstanding,
|
||
written_off,
|
||
);
|
||
let overdue_days = if outstanding > Decimal::ZERO && as_of > due_date {
|
||
(as_of - due_date).num_days()
|
||
} else {
|
||
0
|
||
};
|
||
Ok(Some(arap::DocState {
|
||
total,
|
||
paid,
|
||
discounted,
|
||
written_off,
|
||
outstanding,
|
||
status,
|
||
overdue_days,
|
||
}))
|
||
}
|
||
|
||
/// [`Self::doc_state`] of an invoice.
|
||
pub fn invoice_state(
|
||
&self,
|
||
id: InvoiceId,
|
||
as_of: NaiveDate,
|
||
) -> Result<Option<arap::DocState>, LedgerError> {
|
||
self.doc_state(arap::DocRef::Invoice(id), as_of)
|
||
}
|
||
|
||
/// [`Self::doc_state`] of a bill.
|
||
pub fn bill_state(
|
||
&self,
|
||
id: BillId,
|
||
as_of: NaiveDate,
|
||
) -> Result<Option<arap::DocState>, LedgerError> {
|
||
self.doc_state(arap::DocRef::Bill(id), as_of)
|
||
}
|
||
|
||
/// The payments a document's events recorded, each once, standing or
|
||
/// not, tagged or not: every `invoice.payment_received` or `bill.paid`
|
||
/// naming it (live or replayed). What a version before document
|
||
/// standing (plan P4b S1) counts as paid, so the store knows when a
|
||
/// payment needs that feature. Refused (TooLargeToCompute), never a
|
||
/// panic, past exact arithmetic: replayed amounts are any size (B-28).
|
||
pub fn payments_recorded(&self, doc: arap::DocRef) -> Result<Decimal, LedgerError> {
|
||
self.payment_events
|
||
.get(&doc)
|
||
.map_or(&[][..], Vec::as_slice)
|
||
.iter()
|
||
.try_fold(Decimal::ZERO, |sum, &a| sum.checked_add(a))
|
||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{doc}'s payments")))
|
||
}
|
||
|
||
/// The payment entries that stand against a document now: each
|
||
/// payment (an entry reversing nothing) that isn't reversed, once, in
|
||
/// entry order. What a void waits on, and what a bounds refusal names
|
||
/// to reverse first; never a payment's reversal, which stands too but
|
||
/// re-instating the payment is not the remedy.
|
||
pub fn standing_payments(&self, doc: arap::DocRef) -> Vec<EntryId> {
|
||
let mut out: Vec<EntryId> = self
|
||
.doc_lines(doc)
|
||
.iter()
|
||
.filter(|l| {
|
||
l.movement == arap::Movement::Payment && !l.reversal && self.in_effect(l.entry)
|
||
})
|
||
.map(|l| l.entry)
|
||
.collect();
|
||
out.sort_unstable();
|
||
out.dedup();
|
||
out
|
||
}
|
||
|
||
/// A document's movements by date, `extra` ones added: `(date, the
|
||
/// day's movements summed)`, in date order, over the dates in `grid`
|
||
/// (each date once; a date with nothing moving is zero).
|
||
fn doc_deltas(
|
||
&self,
|
||
doc: arap::DocRef,
|
||
extra: &[(NaiveDate, Decimal)],
|
||
grid: &[NaiveDate],
|
||
) -> Result<Vec<Decimal>, LedgerError> {
|
||
let mut by_date: std::collections::BTreeMap<NaiveDate, Decimal> =
|
||
grid.iter().map(|&d| (d, Decimal::ZERO)).collect();
|
||
let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures"));
|
||
let moves = self
|
||
.doc_lines(doc)
|
||
.into_iter()
|
||
.map(|l| (l.date, l.amount))
|
||
.chain(extra.iter().copied());
|
||
for (date, amount) in moves {
|
||
let day = by_date.entry(date).or_default();
|
||
*day = day.checked_add(amount).ok_or_else(too_large)?;
|
||
}
|
||
Ok(by_date.into_values().collect())
|
||
}
|
||
|
||
/// Refuse a step that would move `doc` by `extra` (dated movements)
|
||
/// if, at some date, it would then owe less than nothing or more than
|
||
/// its total where it didn't before ([`kernel::bounds_kept`]; rules.md
|
||
/// B-38 INVARIANT 2). Books an earlier version left out of bounds
|
||
/// (a void under a payment that stands) take any step that makes no
|
||
/// date worse, so the remedy (reversing the payment) is accepted.
|
||
fn check_item_bounds(
|
||
&self,
|
||
doc: arap::DocRef,
|
||
extra: &[(NaiveDate, Decimal)],
|
||
) -> Result<(), LedgerError> {
|
||
let (_, _, _, total) = self.doc_header(doc)?;
|
||
let mut grid: Vec<NaiveDate> = self
|
||
.doc_lines(doc)
|
||
.iter()
|
||
.map(|l| l.date)
|
||
.chain(extra.iter().map(|&(d, _)| d))
|
||
.collect();
|
||
grid.sort();
|
||
grid.dedup();
|
||
let before = self.doc_deltas(doc, &[], &grid)?;
|
||
let after = self.doc_deltas(doc, extra, &grid)?;
|
||
let (i, below) = match kernel::bounds_kept(total, &before, &after) {
|
||
kernel::Bounds::Within => return Ok(()),
|
||
kernel::Bounds::Below(i) => (i, true),
|
||
kernel::Bounds::Above(i) => (i, false),
|
||
kernel::Bounds::Overflow => {
|
||
return Err(LedgerError::TooLargeToCompute(format!("{doc}'s figures")));
|
||
}
|
||
};
|
||
let owed: Decimal = after[..=i].iter().sum();
|
||
let on = grid[i];
|
||
let payments: Vec<String> = self
|
||
.standing_payments(doc)
|
||
.iter()
|
||
.map(|e| format!("entry {e}"))
|
||
.collect();
|
||
let standing = if payments.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" (payments standing: {})", payments.join(", "))
|
||
};
|
||
Err(LedgerError::DocumentBounds(if below {
|
||
format!(
|
||
"{doc} would owe {owed} on {on}, less than nothing: more would be paid against it than it is for{standing}. Reverse the payment first (books.reverse_entry)"
|
||
)
|
||
} else {
|
||
format!(
|
||
"{doc} would owe {owed} on {on}, more than its total {total}{standing}: reverse the other payment first (books.reverse_entry), then this one's reversal"
|
||
)
|
||
}))
|
||
}
|
||
|
||
/// What can still be paid on `doc` by a payment dated `date`: the
|
||
/// least it owes on any day from then on (from its own date, for a
|
||
/// payment dated before it), so no later day is overpaid.
|
||
fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result<Decimal, LedgerError> {
|
||
let (doc_date, ..) = self.doc_header(doc)?;
|
||
let from = date.max(doc_date);
|
||
let lines = self.doc_lines(doc);
|
||
let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures"));
|
||
let mut running = Decimal::ZERO;
|
||
for l in lines.iter().filter(|l| l.date <= from) {
|
||
running = running.checked_add(l.amount).ok_or_else(too_large)?;
|
||
}
|
||
let mut room = running;
|
||
for l in lines.iter().filter(|l| l.date > from) {
|
||
running = running.checked_add(l.amount).ok_or_else(too_large)?;
|
||
room = room.min(running);
|
||
}
|
||
Ok(room)
|
||
}
|
||
|
||
/// A payment's deposit or payer account: a postable asset or liability
|
||
/// that holds no role (a bank, a card, a member's payable), never a
|
||
/// control, revenue or expense account (plan P4b.3, "Payment
|
||
/// accounts"). Receivables and payables are held as the control guard
|
||
/// reads them ([`Self::control_role_of`]); the other roles by
|
||
/// [`Self::holds_role`], so a chart's own card at 2000, or a bank at a
|
||
/// payroll role's code, pays.
|
||
fn check_payment_account(&self, account: AccountId) -> Result<(), LedgerError> {
|
||
self.check_line_account(account)?;
|
||
let a = self.chart.get(account).expect("checked above");
|
||
let refuse = |why: String| {
|
||
Err(LedgerError::PaymentAccount {
|
||
code: a.code.clone(),
|
||
name: a.name.clone(),
|
||
why,
|
||
})
|
||
};
|
||
if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) {
|
||
return refuse(format!(
|
||
"it is {} account, not cash, a card or a payable",
|
||
match a.account_type {
|
||
AccountType::Equity => "an equity",
|
||
AccountType::Revenue => "a revenue",
|
||
_ => "an expense",
|
||
}
|
||
));
|
||
}
|
||
if let Some(c) = self.control_role_of(account).or_else(|| {
|
||
control_accounts().find(|c| {
|
||
c.key != RECEIVABLES.key && c.key != PAYABLES.key && self.holds_role(c, account)
|
||
})
|
||
}) {
|
||
return refuse(format!("it holds the {} role", c.role));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether `account` fills role `c`: assigned to it, or (unassigned)
|
||
/// the account at the role's code, of the role's type and postable,
|
||
/// so the commands posting the role post there. An account of
|
||
/// another type at the code fills nothing (the commands refuse it:
|
||
/// [`Self::check_controls`]).
|
||
fn holds_role(&self, c: &ControlAccount, account: AccountId) -> bool {
|
||
match self.roles.get(c.key) {
|
||
Some(&a) => a == account,
|
||
None => self.chart.find_by_code(c.code).is_some_and(|a| {
|
||
a.id == account && a.account_type == c.account_type && !a.placeholder
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Record a customer payment against an invoice. Posts: Dr Cash,
|
||
/// Cr A/R ([`Self::receive_payment_with`], with no payment method or
|
||
/// reference).
|
||
pub fn receive_payment(
|
||
&mut self,
|
||
invoice_id: InvoiceId,
|
||
date: NaiveDate,
|
||
amount: Decimal,
|
||
cash_account: AccountId,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.receive_payment_with(
|
||
invoice_id,
|
||
date,
|
||
amount,
|
||
cash_account,
|
||
&arap::PaymentDetails::default(),
|
||
)
|
||
}
|
||
|
||
/// Record a customer payment against an invoice, with its payment
|
||
/// method and reference. Posts: Dr the deposit account, Cr A/R.
|
||
///
|
||
/// ```text
|
||
/// GOAL: record a customer's payment against an invoice.
|
||
/// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow); rules.md
|
||
/// B-38; plan/served.md P4b.3 ("Payment accounts"), P4b.6.
|
||
/// INVARIANTS:
|
||
/// 1. MUST post Dr the deposit account, Cr 1200 receivables, for the
|
||
/// amount; the receivables line carries the invoice (Payment), so
|
||
/// what it owes falls by the amount from the payment's date.
|
||
/// 2. The payment method, when given, tags the entry in the same
|
||
/// step (D-40); the reference is in its description.
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the amount isn't positive: SHOULD refuse.
|
||
/// 2. IF it exceeds what's still owed on any day from its date on:
|
||
/// - SHOULD refuse (Overpayment), saying what can be paid
|
||
/// - NEVER let a standing payment exceed the total
|
||
/// - a reversed payment no longer counts, so the right one is
|
||
/// accepted after the wrong one is reversed (B-38 scenario 5)
|
||
/// 3. IF the deposit account is a control, revenue, expense or
|
||
/// equity account, or holds any role: SHOULD refuse
|
||
/// (PaymentAccount; B-38 scenario 8).
|
||
/// 4. IF dated before the invoice: SHOULD refuse (PaymentBeforeDocument).
|
||
/// ```
|
||
pub fn receive_payment_with(
|
||
&mut self,
|
||
invoice_id: InvoiceId,
|
||
date: NaiveDate,
|
||
amount: Decimal,
|
||
cash_account: AccountId,
|
||
details: &arap::PaymentDetails,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.atomically(|l| {
|
||
l.payment_steps(
|
||
arap::DocRef::Invoice(invoice_id),
|
||
date,
|
||
amount,
|
||
cash_account,
|
||
details,
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Pay a vendor bill. Posts: Dr A/P, Cr Cash ([`Self::pay_bill_with`],
|
||
/// with no payment method or reference).
|
||
pub fn pay_bill(
|
||
&mut self,
|
||
bill_id: BillId,
|
||
date: NaiveDate,
|
||
amount: Decimal,
|
||
cash_account: AccountId,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.pay_bill_with(
|
||
bill_id,
|
||
date,
|
||
amount,
|
||
cash_account,
|
||
&arap::PaymentDetails::default(),
|
||
)
|
||
}
|
||
|
||
/// Pay a vendor bill, with its payment method and reference. Posts:
|
||
/// Dr A/P, Cr the payer account.
|
||
///
|
||
/// ```text
|
||
/// GOAL: record paying a vendor's bill.
|
||
/// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow); rules.md
|
||
/// B-38; plan/served.md P4b.3, P4b.6.
|
||
/// INVARIANTS:
|
||
/// 1. MUST post Dr 2000 payables, Cr the payer account, for the
|
||
/// amount, tagged with the vendor; the payables line carries the
|
||
/// bill (Payment).
|
||
/// 2. The payment method, when given, tags the entry in the same
|
||
/// step (a card-paid bill is left out of 1099-NEC, D-40).
|
||
/// 3. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the amount isn't positive, or exceeds what's still owed on
|
||
/// any day from its date on: SHOULD refuse.
|
||
/// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument);
|
||
/// the bill's own date is fine (DECISION, owner 2026-10-13).
|
||
/// 3. IF the payer account isn't a bank, card or payable that holds
|
||
/// no role: SHOULD refuse (PaymentAccount).
|
||
/// ```
|
||
pub fn pay_bill_with(
|
||
&mut self,
|
||
bill_id: BillId,
|
||
date: NaiveDate,
|
||
amount: Decimal,
|
||
cash_account: AccountId,
|
||
details: &arap::PaymentDetails,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.atomically(|l| {
|
||
l.payment_steps(
|
||
arap::DocRef::Bill(bill_id),
|
||
date,
|
||
amount,
|
||
cash_account,
|
||
details,
|
||
)
|
||
})
|
||
}
|
||
|
||
/// The steps of a payment ([`Self::receive_payment_with`],
|
||
/// [`Self::pay_bill_with`]), run inside [`Self::atomically`].
|
||
fn payment_steps(
|
||
&mut self,
|
||
doc: arap::DocRef,
|
||
date: NaiveDate,
|
||
amount: Decimal,
|
||
account: AccountId,
|
||
details: &arap::PaymentDetails,
|
||
) -> Result<EntryId, LedgerError> {
|
||
if amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
||
}
|
||
let (doc_date, ..) = self.doc_header(doc)?;
|
||
let room = self.payment_room(doc, date)?;
|
||
if amount > room {
|
||
return Err(LedgerError::Overpayment(amount, room));
|
||
}
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
if date < doc_date {
|
||
return Err(LedgerError::PaymentBeforeDocument {
|
||
date,
|
||
document_date: doc_date,
|
||
});
|
||
}
|
||
details.check()?;
|
||
if let Some(i) = details.instrument
|
||
&& !self.instruments.contains_key(&i)
|
||
{
|
||
return Err(LedgerError::UnknownInstrument(i));
|
||
}
|
||
let role = match doc {
|
||
arap::DocRef::Invoice(_) => RECEIVABLES,
|
||
arap::DocRef::Bill(_) => PAYABLES,
|
||
};
|
||
self.check_controls(&[role])?;
|
||
// Every check before the entry exists: a refusal leaves no draft.
|
||
self.check_payment_account(account)?;
|
||
let control = self.ensure_control(&role);
|
||
let money = Money::new(amount, self.base_currency);
|
||
let tag = arap::DocTag {
|
||
doc,
|
||
movement: arap::Movement::Payment,
|
||
};
|
||
let entry = match doc {
|
||
arap::DocRef::Invoice(id) => {
|
||
let entry = self.create_entry(
|
||
date,
|
||
&details.description(&format!("Payment on invoice {id}")),
|
||
);
|
||
self.add_line(entry, account, Side::Debit, money)?;
|
||
self.add_tagged_line(entry, control, Side::Credit, money, tag)?;
|
||
entry
|
||
}
|
||
arap::DocRef::Bill(id) => {
|
||
let entry =
|
||
self.create_entry(date, &details.description(&format!("Payment on bill {id}")));
|
||
self.add_tagged_line(entry, control, Side::Debit, money, tag)?;
|
||
self.add_line(entry, account, Side::Credit, money)?;
|
||
entry
|
||
}
|
||
};
|
||
self.post_entry(entry)?;
|
||
match doc {
|
||
arap::DocRef::Invoice(id) => {
|
||
let customer = self.invoices[&id].customer_id;
|
||
self.tag_customer(entry, customer);
|
||
}
|
||
arap::DocRef::Bill(id) => {
|
||
let vendor = self.bills[&id].vendor_id;
|
||
self.tag_vendor(entry, vendor);
|
||
}
|
||
}
|
||
if details.instrument.is_some() {
|
||
self.set_entry_instrument(entry, details.instrument)?;
|
||
}
|
||
self.payment_events.entry(doc).or_default().push(amount);
|
||
self.refresh_doc(doc);
|
||
Ok(entry)
|
||
}
|
||
|
||
/// Void a document: reverse its own entry (B-9), described "Void
|
||
/// invoice 7: reason". It keeps its id (and, later, its number); its
|
||
/// status as of the void's date on is Void. Plain
|
||
/// [`Self::reverse_entry`] of the same entry gives the same result.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a document that should never have existed (a duplicate, the
|
||
/// wrong customer) is undone by an opposite entry, never erased.
|
||
/// SOURCE: docs/spec/rules.md B-9, B-10, B-12, B-38 (scenarios 2, 3);
|
||
/// plan/served.md P4b.5, P4b.6; ASC 250 (an immaterial error is
|
||
/// corrected in the period found).
|
||
/// INVARIANTS:
|
||
/// 1. MUST post exactly the reversal reverse_entry would, of the
|
||
/// entry whose reversal voids it (its own entry, or the last
|
||
/// reversal in its chain when it was voided and re-instated).
|
||
/// 2. A refusal MUST leave the books unchanged.
|
||
/// SCENARIOS:
|
||
/// 1. IF the reason is blank, more than one line, or longer than 200
|
||
/// characters: SHOULD refuse (InvalidVoid).
|
||
/// 2. IF it is void already: SHOULD refuse (InvalidVoid), naming the
|
||
/// reversal (reverse that to re-instate it).
|
||
/// 3. IF a payment stands against it: SHOULD refuse (DocumentBounds),
|
||
/// naming the payment to reverse first.
|
||
/// 4. IF dated before the document, or in a closed period: SHOULD
|
||
/// refuse (check_reversal).
|
||
/// ```
|
||
pub fn void_document(
|
||
&mut self,
|
||
doc: arap::DocRef,
|
||
date: NaiveDate,
|
||
reason: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
let (target, description) = self.check_void_document(doc, date, reason)?;
|
||
self.reverse_entry(target, date, &description)
|
||
}
|
||
|
||
/// Every refusal of [`Self::void_document`], without touching the
|
||
/// books: the entry to reverse and the reversal's description.
|
||
pub fn check_void_document(
|
||
&self,
|
||
doc: arap::DocRef,
|
||
date: NaiveDate,
|
||
reason: &str,
|
||
) -> Result<(EntryId, String), LedgerError> {
|
||
let reason = reason.trim();
|
||
if reason.is_empty()
|
||
|| reason.contains(['\n', '\r'])
|
||
|| reason.chars().count() > arap::MAX_REFERENCE_CHARS
|
||
{
|
||
return Err(LedgerError::InvalidVoid(format!(
|
||
"a void needs its reason: one line of at most {} characters",
|
||
arap::MAX_REFERENCE_CHARS
|
||
)));
|
||
}
|
||
let (_, _, origin, _) = self.doc_header(doc)?;
|
||
let chain = self.reversal_chain(origin);
|
||
let target = *chain.last().unwrap_or(&origin);
|
||
if !self.in_effect(origin) {
|
||
return Err(LedgerError::InvalidVoid(format!(
|
||
"{doc} is void already: entry {target} reversed it (reverse that entry to re-instate it)"
|
||
)));
|
||
}
|
||
self.check_reversal(target, date)?;
|
||
Ok((target, format!("Void {doc}: {reason}")))
|
||
}
|
||
|
||
/// The control accounts' tie-out as of a day (plan P4b.10,
|
||
/// `books.control_check`): for each control account
|
||
/// ([`Self::control_role_of`]: assigned receivables or payables, or
|
||
/// holding a document's lines), its balance, what its documents owe
|
||
/// on it, its lines that carry no document, and (on the role's first
|
||
/// account) the role's documents needing attention.
|
||
///
|
||
/// ```text
|
||
/// GOAL: show that receivables and payables equal their documents, and
|
||
/// list every line that is no document's.
|
||
/// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1:
|
||
/// GL(A, D) = Σ_docs outstanding(d, D) + unattached(A, D).
|
||
/// INVARIANTS:
|
||
/// 1. balance sums every posted line on the account dated on or
|
||
/// before `as_of`; documents_total sums the tagged ones through
|
||
/// their documents (Self::doc_lines); unattached lists the rest,
|
||
/// each reversal chain once, at what its lines net to as of the
|
||
/// day (a reversed hand line nets to nothing and is not listed).
|
||
/// 2. difference = balance - documents_total - Σ unattached is zero
|
||
/// in every books (an identity: both sides sum the same lines).
|
||
/// 3. attention lists each document of the role issued by `as_of`
|
||
/// whose state then is out of bounds (a credit balance, which
|
||
/// aging leaves out, or more owed than its total) or Unattached:
|
||
/// what books an earlier version wrote can hold (plan P4b.14
|
||
/// item 3), never what these commands write.
|
||
/// 4. NEVER panics: TooLargeToCompute past exact arithmetic.
|
||
/// ```
|
||
pub fn control_check(&self, as_of: NaiveDate) -> Result<Vec<arap::ControlCheck>, LedgerError> {
|
||
let too_large = || LedgerError::TooLargeToCompute("a control account's balance".into());
|
||
// Each control account (Self::control_role_of), and the role
|
||
// whose documents it holds: a debit counts plus on receivables, a
|
||
// credit on payables.
|
||
let mut accounts: Vec<(AccountId, &'static ControlAccount)> = [&RECEIVABLES, &PAYABLES]
|
||
.into_iter()
|
||
.flat_map(|c| {
|
||
self.document_role_accounts(c)
|
||
.into_iter()
|
||
.map(move |a| (a, c))
|
||
})
|
||
.collect();
|
||
let mut doc_totals: HashMap<AccountId, Decimal> = HashMap::new();
|
||
let mut attention: HashMap<&'static str, Vec<arap::DocAttention>> = HashMap::new();
|
||
for doc in self.documents() {
|
||
let role = match doc {
|
||
arap::DocRef::Invoice(_) => &RECEIVABLES,
|
||
arap::DocRef::Bill(_) => &PAYABLES,
|
||
};
|
||
for l in self.doc_lines(doc).into_iter().filter(|l| l.date <= as_of) {
|
||
let t = doc_totals.entry(l.account).or_default();
|
||
*t = t.checked_add(l.amount).ok_or_else(too_large)?;
|
||
}
|
||
if let Some(st) = self.doc_state(doc, as_of)?
|
||
&& (st.out_of_bounds() || st.status == arap::DocStatus::Unattached)
|
||
{
|
||
attention
|
||
.entry(role.key)
|
||
.or_default()
|
||
.push(arap::DocAttention {
|
||
doc,
|
||
status: st.status,
|
||
total: st.total,
|
||
outstanding: st.outstanding,
|
||
});
|
||
}
|
||
}
|
||
// A role whose documents carry no line anywhere (each unattached)
|
||
// still gets its row, on the account the role posts to now.
|
||
for c in [&RECEIVABLES, &PAYABLES] {
|
||
if attention.contains_key(c.key)
|
||
&& !accounts.iter().any(|&(_, r)| r.key == c.key)
|
||
&& let Some(a) = self.role_account(c)
|
||
{
|
||
accounts.push((a, c));
|
||
}
|
||
}
|
||
let counted = |role: &ControlAccount, side: Side, amount: Decimal| {
|
||
let plus = if role.key == RECEIVABLES.key {
|
||
Side::Debit
|
||
} else {
|
||
Side::Credit
|
||
};
|
||
if side == plus { amount } else { -amount }
|
||
};
|
||
let mut out = Vec::new();
|
||
for (account, role) in accounts {
|
||
let mut balance = Decimal::ZERO;
|
||
let mut tops: Vec<(EntryId, usize)> = Vec::new();
|
||
for e in self.posted_entries().filter(|e| e.date() <= as_of) {
|
||
for (i, l) in e.lines().iter().enumerate() {
|
||
if l.account != account {
|
||
continue;
|
||
}
|
||
balance = balance
|
||
.checked_add(counted(role, l.side, l.amount.amount))
|
||
.ok_or_else(too_large)?;
|
||
if !self.line_docs.contains_key(&(e.id(), i)) {
|
||
let top = self.reversal_chain(e.id())[0];
|
||
if !tops.contains(&(top, i)) {
|
||
tops.push((top, i));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut unattached = Vec::new();
|
||
let mut unattached_sum = Decimal::ZERO;
|
||
for (top, i) in tops {
|
||
// The chain's lines dated by `as_of`, each the mirror of
|
||
// the one before: they net to k times the top's line.
|
||
let mut net = Decimal::ZERO;
|
||
for (n, e) in self.reversal_chain(top).into_iter().enumerate() {
|
||
let Some(e) = self.entries.get(&e).filter(|e| e.date() <= as_of) else {
|
||
continue;
|
||
};
|
||
let Some(l) = e.lines().get(i) else {
|
||
continue;
|
||
};
|
||
debug_assert!(n == 0 || e.reversal_of().is_some());
|
||
net = net
|
||
.checked_add(counted(role, l.side, l.amount.amount))
|
||
.ok_or_else(too_large)?;
|
||
}
|
||
if !net.is_zero() {
|
||
unattached_sum = unattached_sum.checked_add(net).ok_or_else(too_large)?;
|
||
unattached.push(arap::UnattachedLine {
|
||
entry: top,
|
||
line: i,
|
||
date: self.entries[&top].date(),
|
||
amount: net,
|
||
});
|
||
}
|
||
}
|
||
let documents_total = doc_totals.get(&account).copied().unwrap_or_default();
|
||
let difference = balance
|
||
.checked_sub(documents_total)
|
||
.and_then(|d| d.checked_sub(unattached_sum))
|
||
.ok_or_else(too_large)?;
|
||
out.push(arap::ControlCheck {
|
||
role: role.key.to_string(),
|
||
account,
|
||
balance,
|
||
documents_total,
|
||
unattached,
|
||
difference,
|
||
attention: attention.remove(role.key).unwrap_or_default(),
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Refresh a document's record figures ([`Invoice::amount_paid`],
|
||
/// [`Invoice::owed`], [`Invoice::status`]) from its tagged lines as of
|
||
/// the end of the books: what was settled (paid, discounted, written
|
||
/// off, as they stand), what is owed, and the three-word status of
|
||
/// what was settled. A void settles nothing, so it never reads as
|
||
/// paid. Never refuses: a document whose figures can't be computed
|
||
/// keeps the last ones (its state refuses them, `doc_state`).
|
||
fn refresh_doc(&mut self, doc: arap::DocRef) {
|
||
let Ok(Some(st)) = self.doc_state(doc, NaiveDate::MAX) else {
|
||
return;
|
||
};
|
||
let Some(settled) = st
|
||
.paid
|
||
.checked_add(st.discounted)
|
||
.and_then(|s| s.checked_add(st.written_off))
|
||
else {
|
||
return;
|
||
};
|
||
// Nothing settled is zero as the record always started it (a
|
||
// sum's scale would show as "0.00"); what is owed keeps the
|
||
// lines' scale, as the total less the settled did ("0.00").
|
||
let settled = if settled.is_zero() {
|
||
Decimal::ZERO
|
||
} else {
|
||
settled
|
||
};
|
||
let owed = st.outstanding;
|
||
let status = arap::derive_status(settled, st.total);
|
||
match doc {
|
||
arap::DocRef::Invoice(id) => {
|
||
if let Some(i) = self.invoices.get_mut(&id) {
|
||
(i.amount_paid, i.owed, i.status) = (settled, owed, status);
|
||
}
|
||
}
|
||
arap::DocRef::Bill(id) => {
|
||
if let Some(b) = self.bills.get_mut(&id) {
|
||
(b.amount_paid, b.owed, b.status) = (settled, owed, status);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// [`Self::refresh_doc`] for every document `entry`'s lines carry.
|
||
fn refresh_docs_of(&mut self, entry: EntryId) {
|
||
let docs: Vec<arap::DocRef> = self
|
||
.line_docs
|
||
.range((entry, 0)..(entry + 1, 0))
|
||
.map(|(_, t)| t.doc)
|
||
.collect();
|
||
for d in docs {
|
||
self.refresh_doc(d);
|
||
}
|
||
}
|
||
|
||
/// On replay, tag a document's control line in `entry` (plan P4b.3,
|
||
/// "How tags get set"): the first line, in line order, on the role's
|
||
/// account as the role stands at this point of the journal, on the
|
||
/// side the movement posts (the owed side for the document's own
|
||
/// line, the other for a payment), that carries no document yet and
|
||
/// is for `amount` (any amount when `None`: a document's own entry
|
||
/// has one such line). An entry with
|
||
/// no such line stays untagged, and its line is listed by
|
||
/// [`Self::control_check`]: replay never refuses (B-28).
|
||
fn tag_replayed(
|
||
&mut self,
|
||
entry: EntryId,
|
||
role: &ControlAccount,
|
||
amount: Option<Decimal>,
|
||
tag: arap::DocTag,
|
||
) {
|
||
let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else {
|
||
return;
|
||
};
|
||
// The side the movement posts: a document's own line raises what
|
||
// it owes, a payment lowers it. An older payment deposited into
|
||
// the control account itself (Dr 1200 / Cr 1200, plan P4b.2 gap
|
||
// 5) has a line on each side; only the right one is the payment.
|
||
let side = match tag.movement {
|
||
arap::Movement::Origin => tag.doc.owed_side(),
|
||
_ => kernel::flip(tag.doc.owed_side()),
|
||
};
|
||
let at = e.lines().iter().enumerate().position(|(i, l)| {
|
||
l.account == account
|
||
&& l.side == side
|
||
&& !self.line_docs.contains_key(&(entry, i))
|
||
&& amount.is_none_or(|a| a == l.amount.amount)
|
||
});
|
||
if let Some(i) = at {
|
||
// A document's own entry has one control line: two would be no
|
||
// entry an earlier version wrote, so neither is tagged.
|
||
let lines_on = e.lines().iter().filter(|l| l.account == account).count();
|
||
if amount.is_some() || lines_on == 1 {
|
||
self.tag_line(entry, i, tag);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Record an invoice without posting: replay support for journaling
|
||
/// stores. The invoice's entry was already rebuilt from its own
|
||
/// `entry.created` / `entry.line_added` / `entry.posted` events; this
|
||
/// restores the invoice record and tags its receivable line (the one
|
||
/// line on the receivables role's account as it stands at this point
|
||
/// of the journal). The id must be the next invoice id — anything else
|
||
/// means the journal and the allocator disagree.
|
||
#[doc(hidden)]
|
||
pub fn replay_invoice(
|
||
&mut self,
|
||
id: InvoiceId,
|
||
customer_id: CustomerId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
if id != self.next_invoice_id {
|
||
return Err(LedgerError::ReplayIdMismatch(id, self.next_invoice_id));
|
||
}
|
||
// Restored exactly as journaled: replay checks no line or date
|
||
// (none did when it was written; rules.md B-28). Figures derived
|
||
// from them (the total, aging) refuse what they can't compute.
|
||
self.next_invoice_id += 1;
|
||
self.invoices.insert(
|
||
id,
|
||
Invoice {
|
||
id,
|
||
customer_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
amount_paid: Decimal::ZERO,
|
||
owed: Decimal::ZERO,
|
||
status: DocumentStatus::Open,
|
||
entry_id,
|
||
number: None,
|
||
terms: None,
|
||
bill_to: None,
|
||
seller: None,
|
||
customer_ref: None,
|
||
memo: None,
|
||
sends: Vec::new(),
|
||
},
|
||
);
|
||
self.tag_customer(entry_id, customer_id);
|
||
let doc = arap::DocRef::Invoice(id);
|
||
self.tag_replayed(
|
||
entry_id,
|
||
&RECEIVABLES,
|
||
None,
|
||
arap::DocTag {
|
||
doc,
|
||
movement: arap::Movement::Origin,
|
||
},
|
||
);
|
||
self.refresh_doc(doc);
|
||
Ok(())
|
||
}
|
||
|
||
/// Record a vendor bill without posting: replay support for journaling
|
||
/// stores. See [`Self::replay_invoice`].
|
||
#[doc(hidden)]
|
||
pub fn replay_bill(
|
||
&mut self,
|
||
id: BillId,
|
||
vendor_id: VendorId,
|
||
date: NaiveDate,
|
||
due_date: NaiveDate,
|
||
lines: Vec<DocumentLine>,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
if id != self.next_bill_id {
|
||
return Err(LedgerError::ReplayIdMismatch(id, self.next_bill_id));
|
||
}
|
||
// Restored exactly as journaled: replay checks no line or date
|
||
// (none did when it was written; rules.md B-28). Figures derived
|
||
// from them (the total, aging) refuse what they can't compute.
|
||
self.next_bill_id += 1;
|
||
self.bills.insert(
|
||
id,
|
||
Bill {
|
||
id,
|
||
vendor_id,
|
||
date,
|
||
due_date,
|
||
lines,
|
||
amount_paid: Decimal::ZERO,
|
||
owed: Decimal::ZERO,
|
||
status: DocumentStatus::Open,
|
||
entry_id,
|
||
vendor_ref: None,
|
||
received_on: None,
|
||
terms: None,
|
||
memo: None,
|
||
},
|
||
);
|
||
self.tag_vendor(entry_id, vendor_id);
|
||
let doc = arap::DocRef::Bill(id);
|
||
self.tag_replayed(
|
||
entry_id,
|
||
&PAYABLES,
|
||
None,
|
||
arap::DocTag {
|
||
doc,
|
||
movement: arap::Movement::Origin,
|
||
},
|
||
);
|
||
self.refresh_doc(doc);
|
||
Ok(())
|
||
}
|
||
|
||
/// Apply an invoice payment without posting: replay support for
|
||
/// journaling stores. The payment entry was already rebuilt from its
|
||
/// own entry events; this tags its receivables line with the invoice
|
||
/// (the first untagged one for `amount`, so several payment events
|
||
/// naming one entry tag its lines in order: the ordinal rule, plan
|
||
/// P4b.3). Nothing is bounded on replay (rules.md B-28).
|
||
#[doc(hidden)]
|
||
pub fn replay_invoice_payment(
|
||
&mut self,
|
||
invoice_id: InvoiceId,
|
||
amount: Decimal,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
let customer = self
|
||
.invoices
|
||
.get(&invoice_id)
|
||
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
|
||
.customer_id;
|
||
let doc = arap::DocRef::Invoice(invoice_id);
|
||
self.tag_replayed(
|
||
entry_id,
|
||
&RECEIVABLES,
|
||
Some(amount),
|
||
arap::DocTag {
|
||
doc,
|
||
movement: arap::Movement::Payment,
|
||
},
|
||
);
|
||
self.tag_customer(entry_id, customer);
|
||
self.payment_events.entry(doc).or_default().push(amount);
|
||
self.refresh_doc(doc);
|
||
Ok(())
|
||
}
|
||
|
||
/// Apply a bill payment without posting: replay support for journaling
|
||
/// stores. See [`Self::replay_invoice_payment`].
|
||
#[doc(hidden)]
|
||
pub fn replay_bill_payment(
|
||
&mut self,
|
||
bill_id: BillId,
|
||
amount: Decimal,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
let vendor = self
|
||
.bills
|
||
.get(&bill_id)
|
||
.ok_or(LedgerError::UnknownBill(bill_id))?
|
||
.vendor_id;
|
||
let doc = arap::DocRef::Bill(bill_id);
|
||
self.tag_replayed(
|
||
entry_id,
|
||
&PAYABLES,
|
||
Some(amount),
|
||
arap::DocTag {
|
||
doc,
|
||
movement: arap::Movement::Payment,
|
||
},
|
||
);
|
||
self.tag_vendor(entry_id, vendor);
|
||
self.payment_events.entry(doc).or_default().push(amount);
|
||
self.refresh_doc(doc);
|
||
Ok(())
|
||
}
|
||
|
||
/// A/R aging as of a date: one row per customer with an outstanding
|
||
/// balance, bucketed by days past due. Refused (TooLargeToCompute),
|
||
/// never a panic, when a sum is past exact arithmetic: documents are
|
||
/// restored as journaled (rules.md B-28).
|
||
///
|
||
/// ```text
|
||
/// GOAL: what each customer owes as of a day, by how overdue.
|
||
/// SOURCE: docs/spec/rules.md B-18, B-38 INVARIANT 5.
|
||
/// INVARIANTS:
|
||
/// 1. Each invoice issued by `as_of` that owes something then
|
||
/// (Self::doc_state's outstanding) is in exactly one bucket, at
|
||
/// that amount: the buckets partition what the invoices owe.
|
||
/// 2. A void invoice, a paid one, and one dated after `as_of` are
|
||
/// left out; a payment reversed after `as_of` still counts as
|
||
/// paid, one reversed by then doesn't.
|
||
/// 3. One owing less than nothing (a credit balance, in books an
|
||
/// earlier version left out of bounds) is left out too: it is
|
||
/// owed back, not receivable (ASC 310-10), and
|
||
/// Self::control_check lists it. So the buckets sum to what the
|
||
/// invoices owing something owe: the documents' total less those
|
||
/// credit balances.
|
||
/// ```
|
||
pub fn ar_aging(&self, as_of: NaiveDate) -> Result<Vec<AgingRow>, LedgerError> {
|
||
self.aging(as_of, true)
|
||
}
|
||
|
||
/// A/P aging as of a date: one row per vendor with an outstanding
|
||
/// balance, bucketed by days past due; as [`Self::ar_aging`] is.
|
||
pub fn ap_aging(&self, as_of: NaiveDate) -> Result<Vec<AgingRow>, LedgerError> {
|
||
self.aging(as_of, false)
|
||
}
|
||
|
||
/// [`Self::ar_aging`] (`invoices`) or [`Self::ap_aging`].
|
||
fn aging(&self, as_of: NaiveDate, invoices: bool) -> Result<Vec<AgingRow>, LedgerError> {
|
||
let mut by_party: HashMap<u64, AgingRow> = HashMap::new();
|
||
for doc in self.documents() {
|
||
let (party, name) = match doc {
|
||
arap::DocRef::Invoice(id) if invoices => {
|
||
let c = self.invoices[&id].customer_id;
|
||
(c, self.customers.get(&c).map(|c| c.name.clone()))
|
||
}
|
||
arap::DocRef::Bill(id) if !invoices => {
|
||
let v = self.bills[&id].vendor_id;
|
||
(v, self.vendors.get(&v).map(|v| v.name.clone()))
|
||
}
|
||
_ => continue,
|
||
};
|
||
let Some(st) = self.doc_state(doc, as_of)? else {
|
||
continue;
|
||
};
|
||
// Nothing owed, or a credit balance (Overpaid, or a void under
|
||
// a payment that stands): no receivable or payable to age; the
|
||
// control check lists the credit balances.
|
||
if st.outstanding <= Decimal::ZERO {
|
||
continue;
|
||
}
|
||
let (_, due_date, ..) = self.doc_header(doc)?;
|
||
let row = arap::aging_bucket(due_date, as_of, st.outstanding);
|
||
by_party
|
||
.entry(party)
|
||
.or_insert_with(|| AgingRow {
|
||
name: name.unwrap_or_default(),
|
||
current: Decimal::ZERO,
|
||
days_30: Decimal::ZERO,
|
||
days_60: Decimal::ZERO,
|
||
days_90: Decimal::ZERO,
|
||
days_90_plus: Decimal::ZERO,
|
||
})
|
||
.add(&row)?;
|
||
}
|
||
let mut rows: Vec<AgingRow> = by_party.into_values().collect();
|
||
rows.sort_by(|a, b| a.name.cmp(&b.name));
|
||
Ok(rows)
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Bank reconciliation
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Mark a posted entry's line as cleared (or uncleared). The cleared
|
||
/// flag is a workflow marker, not accounting data: posted entries stay
|
||
/// immutable, and the flag lives in a side table.
|
||
///
|
||
/// ```text
|
||
/// GOAL: mark a posted line as cleared (seen on the bank statement), or not.
|
||
/// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry is unknown, a draft, or has no such line: SHOULD refuse.
|
||
/// 2. Marking MUST change no amount, no report but the reconciliation.
|
||
/// ```
|
||
pub fn mark_cleared(
|
||
&mut self,
|
||
entry_id: EntryId,
|
||
line_index: usize,
|
||
cleared: bool,
|
||
) -> Result<(), LedgerError> {
|
||
let entry = self
|
||
.entries
|
||
.get(&entry_id)
|
||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||
if entry.status() != EntryStatus::Posted {
|
||
return Err(LedgerError::ClearedOnDraft(entry_id));
|
||
}
|
||
if line_index >= entry.lines().len() {
|
||
return Err(LedgerError::LineIndexOutOfRange(line_index, entry_id));
|
||
}
|
||
if cleared {
|
||
self.cleared.insert((entry_id, line_index), true);
|
||
} else {
|
||
self.cleared.remove(&(entry_id, line_index));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// True if the given posted line is marked cleared.
|
||
pub fn is_cleared(&self, entry_id: EntryId, line_index: usize) -> bool {
|
||
self.cleared
|
||
.get(&(entry_id, line_index))
|
||
.copied()
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Reconcile a bank/cash account against a statement. Compares the
|
||
/// cleared balance (posted lines marked cleared, dated on or before
|
||
/// the statement date) to the statement balance, and lists every
|
||
/// uncleared line so the human can see what's outstanding.
|
||
///
|
||
/// ```text
|
||
/// GOAL: compare the books with a bank statement.
|
||
/// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation.
|
||
/// INVARIANTS:
|
||
/// 1. book_balance MUST be the account's net of all posted lines.
|
||
/// 2. cleared_balance MUST be the net of cleared lines dated on or
|
||
/// before the statement date.
|
||
/// 3. difference MUST equal cleared_balance - statement_balance; zero
|
||
/// means reconciled.
|
||
/// 4. uncleared_lines MUST list exactly the posted, uncleared lines on
|
||
/// the account dated on or before the statement date.
|
||
/// ```
|
||
pub fn reconciliation_report(
|
||
&self,
|
||
account: AccountId,
|
||
statement_date: NaiveDate,
|
||
statement_balance: Decimal,
|
||
) -> Result<ReconciliationReport, LedgerError> {
|
||
let acct = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
// A statement says what an account holds: no more than a line may
|
||
// move, so the difference is exact (P0 review round 3, S1).
|
||
if !kernel::Amount::in_line_range(statement_balance) {
|
||
return Err(LedgerError::ValueOutOfRange(format!(
|
||
"statement balance {statement_balance}"
|
||
)));
|
||
}
|
||
let normal = acct.account_type.normal_side();
|
||
let mut book = Decimal::ZERO;
|
||
let mut cleared = Decimal::ZERO;
|
||
let mut uncleared_lines = Vec::new();
|
||
for entry in self.posted_entries() {
|
||
for (idx, line) in entry.lines().iter().enumerate() {
|
||
if line.account != account {
|
||
continue;
|
||
}
|
||
// Signed in the account's normal side: positive = normal balance.
|
||
let signed = match (line.side, normal) {
|
||
(Side::Debit, Side::Debit) => line.amount.amount,
|
||
(Side::Credit, Side::Credit) => line.amount.amount,
|
||
(Side::Debit, Side::Credit) => -line.amount.amount,
|
||
(Side::Credit, Side::Debit) => -line.amount.amount,
|
||
};
|
||
book += signed;
|
||
if entry.date() > statement_date {
|
||
continue;
|
||
}
|
||
if self.is_cleared(entry.id(), idx) {
|
||
cleared += signed;
|
||
} else {
|
||
uncleared_lines.push(UnclearedLine {
|
||
entry_id: entry.id(),
|
||
line_index: idx,
|
||
date: entry.date(),
|
||
description: entry.description().to_string(),
|
||
side: line.side,
|
||
amount: line.amount.amount,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
// Sort uncleared by date for a readable report.
|
||
uncleared_lines.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id)));
|
||
Ok(ReconciliationReport {
|
||
account,
|
||
book_balance: if book.is_zero() { Decimal::ZERO } else { book },
|
||
cleared_balance: if cleared.is_zero() {
|
||
Decimal::ZERO
|
||
} else {
|
||
cleared
|
||
},
|
||
statement_balance,
|
||
difference: cleared.checked_sub(statement_balance).ok_or_else(|| {
|
||
LedgerError::TooLargeToCompute("the reconciliation's difference".into())
|
||
})?,
|
||
uncleared_lines,
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Foreign exchange
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Record a historical FX rate for reference: how much base currency
|
||
/// one unit of `currency` buys on `date`. Lines store their own rate at
|
||
/// entry time; this table is for lookup and audit, not conversion.
|
||
pub fn set_fx_rate(
|
||
&mut self,
|
||
currency: Currency,
|
||
date: NaiveDate,
|
||
rate: Decimal,
|
||
) -> Result<(), LedgerError> {
|
||
if rate <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidFxRate(rate));
|
||
}
|
||
if currency == self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: "foreign currency".to_string(),
|
||
found: currency.code().to_string(),
|
||
});
|
||
}
|
||
self.fx_rates.insert((currency, date), rate);
|
||
Ok(())
|
||
}
|
||
|
||
/// Look up a recorded FX rate.
|
||
pub fn get_fx_rate(&self, currency: Currency, date: NaiveDate) -> Option<Decimal> {
|
||
self.fx_rates.get(&(currency, date)).copied()
|
||
}
|
||
|
||
/// Signed net movement of an account across all posted entries:
|
||
/// debits minus credits. Positive means net debit movement.
|
||
pub fn account_movement(&self, account: AccountId) -> Decimal {
|
||
self.net_of(account, |_| true)
|
||
}
|
||
|
||
/// Balance expressed in the account's normal side: positive means a
|
||
/// normal balance, negative means a contra balance.
|
||
///
|
||
/// ```text
|
||
/// GOAL: an account's balance on its normal side.
|
||
/// SOURCE: docs/spec/rules.md B-5.
|
||
/// INVARIANTS:
|
||
/// 1. MUST equal the account's debits minus credits for debit-normal
|
||
/// types (asset, expense), and credits minus debits otherwise.
|
||
/// 2. NEVER return negative zero.
|
||
/// SCENARIOS:
|
||
/// 1. IF the account is unknown: SHOULD refuse.
|
||
/// ```
|
||
pub fn account_balance(&self, account: AccountId) -> Result<Decimal, LedgerError> {
|
||
let account = self
|
||
.chart
|
||
.get(account)
|
||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||
let movement = self.account_movement(account.id);
|
||
let balance = kernel::normal_balance(account.account_type, movement)
|
||
.expect("Decimal negation is exact");
|
||
// Never leak negative zero into reports: it is a representation
|
||
// artifact of negating an exact zero, and money must never print "-0".
|
||
Ok(if balance.is_zero() {
|
||
Decimal::ZERO
|
||
} else {
|
||
balance
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Payroll
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Pure pre-validation for [`Self::run_payroll`]: every check that does
|
||
/// not mutate, so journaling stores can validate before journaling.
|
||
pub fn validate_payroll(
|
||
&self,
|
||
date: NaiveDate,
|
||
lines: &[PayrollLine],
|
||
cash_account: AccountId,
|
||
) -> Result<(), LedgerError> {
|
||
validate_payroll_lines(lines)?;
|
||
// A line that names its employee names a party with that role.
|
||
for line in lines {
|
||
if let Some(id) = line.employee {
|
||
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||
if !p.roles.contains(&crate::Role::Employee) {
|
||
return Err(LedgerError::InvalidParty(format!(
|
||
"{} isn't an employee (give the party the employee role first)",
|
||
p.label()
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
self.check_controls(&PAYROLL)?;
|
||
self.chart
|
||
.get(cash_account)
|
||
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Resolve the seven payroll chart accounts by code, creating them when
|
||
/// missing. Journaling stores ensure these through their own journaled
|
||
/// path first; this then no-ops and replay stays deterministic.
|
||
fn payroll_accounts(&mut self) -> PayrollAccounts {
|
||
let [salary, tax, federal, state, ss, medicare, other] = PAYROLL;
|
||
PayrollAccounts {
|
||
salary_expense: self.ensure_control(&salary),
|
||
payroll_tax_expense: self.ensure_control(&tax),
|
||
federal_payable: self.ensure_control(&federal),
|
||
state_payable: self.ensure_control(&state),
|
||
ss_payable: self.ensure_control(&ss),
|
||
medicare_payable: self.ensure_control(&medicare),
|
||
other_payable: self.ensure_control(&other),
|
||
}
|
||
}
|
||
|
||
/// Run payroll: validate, auto-create the payroll chart accounts, and
|
||
/// post one balanced entry — Dr `6100` Salary & Wage Expense for gross,
|
||
/// Dr `6110` Payroll Tax Expense for the employer FICA share, Cr the
|
||
/// `2110`–`2150` payables for withholdings and deductions, Cr cash for
|
||
/// net pay. Zero totals are skipped, never posted as zero-amount lines.
|
||
///
|
||
/// Accounting only: there are no tax tables here. Every withholding
|
||
/// number is supplied by the caller.
|
||
///
|
||
/// ```text
|
||
/// GOAL: record a payroll: wages, the employer's FICA, what is withheld
|
||
/// and owed, and net pay.
|
||
/// SOURCE: IRS Pub. 15 (docs/spec/sources/irs-payroll.md); R-? Payroll
|
||
/// (accounting only: the caller supplies every number).
|
||
/// INVARIANTS:
|
||
/// 1. MUST post one balanced entry: Dr 6100 gross wages, Dr 6110 employer
|
||
/// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash
|
||
/// net pay.
|
||
/// 2. Net pay MUST equal gross minus the employee's withholdings.
|
||
/// 3. When every line with net pay names its employee party, the entry
|
||
/// names those parties (Self::entry_parties), each with its net pay
|
||
/// (summed over its lines): the whole of it for one employee, else
|
||
/// an exact amount each, so each employee's books.party counts what
|
||
/// they were paid. Lines named by employee_name only (or shares
|
||
/// Self::set_entry_parties would refuse) leave the entry naming no
|
||
/// party (the agent tags it).
|
||
/// SCENARIOS:
|
||
/// 1. IF any amount is negative, gross is zero, or withholdings exceed
|
||
/// gross: SHOULD refuse.
|
||
/// 2. Pat (net 1,500.00) and Sam (net 900.00), both parties: the entry
|
||
/// names Pat 1,500.00 and Sam 900.00.
|
||
/// ```
|
||
pub fn run_payroll(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
lines: Vec<PayrollLine>,
|
||
cash_account: AccountId,
|
||
) -> Result<PayrollRunId, LedgerError> {
|
||
self.atomically(|l| l.run_payroll_steps(date, lines, cash_account))
|
||
}
|
||
|
||
/// The steps of [`Self::run_payroll`], run inside [`Self::atomically`].
|
||
fn run_payroll_steps(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
lines: Vec<PayrollLine>,
|
||
cash_account: AccountId,
|
||
) -> Result<PayrollRunId, LedgerError> {
|
||
self.validate_payroll(date, &lines, cash_account)?;
|
||
let accounts = self.payroll_accounts();
|
||
let entry = self.try_create_entry(date, &format!("Payroll {date}"))?;
|
||
for (account, side, amount) in
|
||
payroll_entry_plan(&lines, accounts, cash_account, self.base_currency)?
|
||
{
|
||
self.add_line(entry, account, side, amount)?;
|
||
}
|
||
self.post_entry(entry)?;
|
||
self.name_payroll_parties(entry, &lines)?;
|
||
let id = self.next_payroll_run_id;
|
||
self.next_payroll_run_id += 1;
|
||
self.payroll_runs.insert(
|
||
id,
|
||
PayrollRun {
|
||
id,
|
||
date,
|
||
lines,
|
||
entry_id: entry,
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// [`Self::run_payroll`] INVARIANT 3: name the employees a payroll
|
||
/// entry paid as its parties, each with their net pay. Live and on
|
||
/// replay ([`Self::replay_payroll_run`]) alike.
|
||
fn name_payroll_parties(
|
||
&mut self,
|
||
entry: EntryId,
|
||
lines: &[PayrollLine],
|
||
) -> Result<(), LedgerError> {
|
||
let mut paid: Vec<(crate::PartyId, Decimal)> = Vec::new();
|
||
let mut unnamed = false;
|
||
for l in lines {
|
||
// A run is restored as journaled (rules.md B-28): its figures
|
||
// are checked, and a net past exact arithmetic refused.
|
||
let net = l.net_pay()?;
|
||
if net.is_zero() {
|
||
continue;
|
||
}
|
||
match l.employee {
|
||
Some(p) => match paid.iter_mut().find(|(x, _)| *x == p) {
|
||
Some((_, v)) => {
|
||
*v = v.checked_add(net).ok_or_else(|| {
|
||
LedgerError::TooLargeToCompute(format!("party {p}'s net pay"))
|
||
})?
|
||
}
|
||
None => paid.push((p, net)),
|
||
},
|
||
None => unnamed = true,
|
||
}
|
||
}
|
||
if !unnamed && !paid.is_empty() {
|
||
let shares: Vec<crate::PartyShare> = match paid.as_slice() {
|
||
[(p, _)] => vec![crate::PartyShare::whole(*p)],
|
||
_ => paid
|
||
.iter()
|
||
.map(|&(party, v)| crate::PartyShare {
|
||
party,
|
||
share: crate::Share::Amount(v),
|
||
})
|
||
.collect(),
|
||
};
|
||
// Shares set_entry_parties would refuse (two employees with
|
||
// vendor records) leave the entry for the agent to tag.
|
||
if self.check_set_entry_parties(entry, &shares).is_ok() {
|
||
self.set_entry_parties(entry, &shares)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Current balances of the payroll tax payable accounts (`2110`–`2150`),
|
||
/// in code order. Accounts that don't exist yet count as zero and are
|
||
/// skipped, as are zero balances. Balances are normal-side: positive
|
||
/// means owed.
|
||
pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> {
|
||
let mut out = Vec::new();
|
||
for c in &PAYROLL[2..] {
|
||
let Some(account) = self.role_account(c) else {
|
||
continue;
|
||
};
|
||
// The role resolved to an account, so the balance lookup
|
||
// cannot fail.
|
||
let balance = self.account_balance(account).unwrap_or(Decimal::ZERO);
|
||
if !balance.is_zero() {
|
||
out.push((account, balance));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Remit payroll taxes: pay the `2110`–`2150` payables out of cash.
|
||
/// Posts one entry — Dr each payable for its balance, Cr cash for the
|
||
/// total. Fails with [`LedgerError::NothingToRemit`] when nothing is
|
||
/// owed, and rejects dates in closed periods.
|
||
///
|
||
/// ```text
|
||
/// GOAL: pay the payroll taxes and withholdings owed.
|
||
/// SOURCE: IRS Pub. 15 (deposits).
|
||
/// INVARIANTS:
|
||
/// 1. MUST zero every payroll payable (2110-2150) against cash.
|
||
/// SCENARIOS:
|
||
/// 1. IF nothing is owed: SHOULD refuse (NothingToRemit).
|
||
/// ```
|
||
pub fn remit_payroll_taxes(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
cash_account: AccountId,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.atomically(|l| l.remit_payroll_taxes_steps(date, cash_account))
|
||
}
|
||
|
||
/// The steps of [`Self::remit_payroll_taxes`], run inside [`Self::atomically`].
|
||
fn remit_payroll_taxes_steps(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
cash_account: AccountId,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.chart
|
||
.get(cash_account)
|
||
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||
self.check_controls(&PAYROLL)?;
|
||
let payables = self.payroll_tax_payables();
|
||
let total: Decimal = payables.iter().map(|(_, b)| *b).sum();
|
||
if total.is_zero() {
|
||
return Err(LedgerError::NothingToRemit);
|
||
}
|
||
if let Some(pid) = self.closed_period_containing(date) {
|
||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||
}
|
||
let entry = self.try_create_entry(date, &format!("Payroll tax remittance {date}"))?;
|
||
for (account, side, amount) in
|
||
payroll_remit_plan(&payables, cash_account, self.base_currency)
|
||
{
|
||
self.add_line(entry, account, side, amount)?;
|
||
}
|
||
self.post_entry(entry)?;
|
||
Ok(entry)
|
||
}
|
||
|
||
/// All payroll runs, in id order.
|
||
pub fn list_payroll_runs(&self) -> Vec<&PayrollRun> {
|
||
let mut ids: Vec<PayrollRunId> = self.payroll_runs.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.payroll_runs.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// Look up a payroll run.
|
||
pub fn get_payroll_run(&self, id: PayrollRunId) -> Option<&PayrollRun> {
|
||
self.payroll_runs.get(&id)
|
||
}
|
||
|
||
/// Record a payroll run without posting: replay support for journaling
|
||
/// stores. The run's entry was already rebuilt from its own
|
||
/// `entry.created` / `entry.line_added` / `entry.posted` events; this
|
||
/// only restores the run record. The id must be the next payroll run
|
||
/// id — anything else means the journal and the allocator disagree.
|
||
#[doc(hidden)]
|
||
pub fn replay_payroll_run(
|
||
&mut self,
|
||
id: PayrollRunId,
|
||
date: NaiveDate,
|
||
lines: Vec<PayrollLine>,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
if id != self.next_payroll_run_id {
|
||
return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
|
||
}
|
||
// Restored exactly as journaled: the run's figures are checked by
|
||
// the command, not by replay (rules.md B-28).
|
||
self.next_payroll_run_id += 1;
|
||
if self.entries.contains_key(&entry_id) {
|
||
self.name_payroll_parties(entry_id, &lines)?;
|
||
}
|
||
self.payroll_runs.insert(
|
||
id,
|
||
PayrollRun {
|
||
id,
|
||
date,
|
||
lines,
|
||
entry_id,
|
||
},
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Settings, conventions, decisions, open items, approvals
|
||
// ------------------------------------------------------------------
|
||
|
||
/// The settings the journal holds ([`crate::settings`]).
|
||
pub fn settings(&self) -> &crate::settings::Settings {
|
||
&self.settings
|
||
}
|
||
|
||
/// Check a settings change before it is journaled: every value
|
||
/// canonical, changes that change nothing left out, the result whole
|
||
/// (crate::settings INVARIANTS 1, 2, 4), and the fiscal year never
|
||
/// moved under a closed period. Changes nothing.
|
||
pub fn check_settings(
|
||
&self,
|
||
changes: &[crate::settings::Change],
|
||
source: &crate::settings::Source,
|
||
) -> Result<Vec<crate::settings::Change>, LedgerError> {
|
||
self.check_settings_adopting(changes, source, false)
|
||
}
|
||
|
||
/// [`Self::check_settings`] for books moving a settings file in
|
||
/// (`adopting`): their first `settings.set` takes the fiscal year they
|
||
/// were kept under (the import proves it is the file's), so it moves
|
||
/// nothing, closed periods or not.
|
||
pub fn check_settings_adopting(
|
||
&self,
|
||
changes: &[crate::settings::Change],
|
||
source: &crate::settings::Source,
|
||
adopting: bool,
|
||
) -> Result<Vec<crate::settings::Change>, LedgerError> {
|
||
let made = self
|
||
.settings
|
||
.check_changes(changes, source)
|
||
.map_err(LedgerError::InvalidSetting)?;
|
||
// The fiscal year can't move under closed periods: they were
|
||
// closed by it, and the year's earnings would restate (a tax year
|
||
// changes only with the IRS's consent, Form 1128). Books moving a
|
||
// settings file in take the year they were kept under.
|
||
if !(adopting && !self.settings.is_journaled())
|
||
&& made.iter().any(|c| c.key == "company.fiscal_year_start")
|
||
{
|
||
let mut next = self.settings.clone();
|
||
next.apply(&made, source, 0)
|
||
.map_err(LedgerError::InvalidSetting)?;
|
||
let (before, after) = (
|
||
self.settings.company("").fiscal_year_start,
|
||
next.company("").fiscal_year_start,
|
||
);
|
||
if before != after
|
||
&& self
|
||
.periods
|
||
.values()
|
||
.any(|p| p.status == crate::PeriodStatus::Closed)
|
||
{
|
||
return Err(LedgerError::InvalidSetting(format!(
|
||
"company.fiscal_year_start can't change from {:02}-{:02} to {:02}-{:02}: periods are closed under it (a changed tax year needs the IRS's consent, Form 1128; ask the CPA)",
|
||
before.0, before.1, after.0, after.1
|
||
)));
|
||
}
|
||
}
|
||
Ok(made)
|
||
}
|
||
|
||
/// Apply a journaled settings change (`settings.set` at `seq`): as
|
||
/// checked when made, or as replayed (shape only, rules.md B-28).
|
||
pub fn apply_settings(
|
||
&mut self,
|
||
changes: &[crate::settings::Change],
|
||
source: &crate::settings::Source,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.settings
|
||
.apply(changes, source, seq)
|
||
.map_err(LedgerError::InvalidSetting)
|
||
}
|
||
|
||
/// Conventions, decisions, open items and approvals.
|
||
pub fn conventions(&self) -> &crate::conventions::Conventions {
|
||
&self.conventions
|
||
}
|
||
|
||
/// Check a convention before it is journaled: its id, text and
|
||
/// structure (crate::conventions::check_new), and that every account a
|
||
/// posting template or approval rule names is in the chart (a
|
||
/// template's, one that takes postings). Returns it as it will be
|
||
/// journaled; changes nothing.
|
||
pub fn check_convention(
|
||
&self,
|
||
c: &crate::conventions::NewConvention,
|
||
) -> Result<crate::conventions::NewConvention, LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
let c = crate::conventions::check_new(c).map_err(bad)?;
|
||
let old = self.conventions.convention(&c.id);
|
||
if old.is_some_and(|o| o.retired.is_some()) {
|
||
return Err(bad(format!(
|
||
"convention {} is retired; ids are never reused: choose a new one",
|
||
c.id
|
||
)));
|
||
}
|
||
if let Some(o) = old.filter(|o| o.kind != c.kind) {
|
||
return Err(bad(format!(
|
||
"convention {} is a {}; its kind can't change (retire it and set a new id)",
|
||
c.id,
|
||
o.kind.key()
|
||
)));
|
||
}
|
||
let probe = crate::conventions::Convention {
|
||
id: c.id.clone(),
|
||
kind: c.kind,
|
||
title: c.title.clone(),
|
||
body_md: c.body_md.clone(),
|
||
structured: c.structured.clone(),
|
||
source: c.source.clone(),
|
||
seq: 0,
|
||
revision: 1,
|
||
retired: None,
|
||
};
|
||
if let Some(t) = probe.template() {
|
||
for l in t
|
||
.lines
|
||
.iter()
|
||
.filter(|l| crate::conventions::slot(&l.account).is_none())
|
||
{
|
||
let a = self.chart.find_by_code(&l.account).ok_or_else(|| {
|
||
bad(format!(
|
||
"template account {} is not in the chart",
|
||
l.account
|
||
))
|
||
})?;
|
||
if a.placeholder {
|
||
return Err(bad(format!(
|
||
"template account {} is a placeholder; templates post to accounts that take postings",
|
||
l.account
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
if let Some(r) = probe.approval_rule() {
|
||
for code in &r.accounts {
|
||
match self.chart.find_by_code(code) {
|
||
None => {
|
||
return Err(bad(format!(
|
||
"approval rule account {code} is not in the chart"
|
||
)));
|
||
}
|
||
Some(a) if a.placeholder => {
|
||
return Err(bad(format!(
|
||
"approval rule account {code} is a placeholder (a heading), which no line posts to: name the accounts under it"
|
||
)));
|
||
}
|
||
Some(_) => {}
|
||
}
|
||
}
|
||
}
|
||
if old.is_some_and(|o| {
|
||
o.title == c.title && o.body_md == c.body_md && o.structured == c.structured
|
||
}) {
|
||
return Err(bad(format!(
|
||
"convention {} already says exactly that",
|
||
c.id
|
||
)));
|
||
}
|
||
Ok(c)
|
||
}
|
||
|
||
/// Apply a journaled `convention.set` at `seq` (checked when made;
|
||
/// replay takes it as journaled).
|
||
pub fn apply_convention(
|
||
&mut self,
|
||
c: crate::conventions::NewConvention,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.conventions
|
||
.apply_convention(c, seq)
|
||
.map_err(LedgerError::InvalidConvention)
|
||
}
|
||
|
||
/// Check a retirement before it is journaled: the convention is live,
|
||
/// the reason and source are sound.
|
||
pub fn check_retire_convention(
|
||
&self,
|
||
id: &str,
|
||
reason: &str,
|
||
source: &crate::settings::Source,
|
||
) -> Result<String, LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
source.check().map_err(bad)?;
|
||
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||
.map_err(bad)?;
|
||
match self.conventions.convention(id) {
|
||
None => Err(bad(format!(
|
||
"unknown convention {id:?}: books.conventions lists them"
|
||
))),
|
||
Some(c) if c.retired.is_some() => {
|
||
Err(bad(format!("convention {id} is already retired")))
|
||
}
|
||
Some(_) => Ok(reason),
|
||
}
|
||
}
|
||
|
||
/// Apply a journaled `convention.retired` at `seq`.
|
||
pub fn apply_retire_convention(
|
||
&mut self,
|
||
id: &str,
|
||
reason: String,
|
||
source: crate::settings::Source,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.conventions
|
||
.apply_retire(id, reason, source, seq)
|
||
.map_err(LedgerError::InvalidConvention)
|
||
}
|
||
|
||
/// Check a decision before it is journaled; returns it as it will be
|
||
/// journaled, under the id it will get. Changes nothing. (Its
|
||
/// evidence attachment is the store's to check.)
|
||
pub fn check_decision(
|
||
&self,
|
||
d: &crate::conventions::NewDecision,
|
||
) -> Result<(u64, crate::conventions::NewDecision), LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
let d = crate::conventions::check_decision(d).map_err(bad)?;
|
||
for (i, p) in d.projects.iter().enumerate() {
|
||
if !self.classes.contains_key(p) {
|
||
return Err(LedgerError::UnknownClass(*p));
|
||
}
|
||
if d.projects[..i].contains(p) {
|
||
return Err(bad(format!("project {p} is named twice")));
|
||
}
|
||
}
|
||
if let Some(old) = d.supersedes {
|
||
match self.conventions.decision(old) {
|
||
None => {
|
||
return Err(bad(format!(
|
||
"decision {old} doesn't exist: books.decisions lists them"
|
||
)));
|
||
}
|
||
Some(o) if o.superseded_by.is_some() => {
|
||
return Err(bad(format!(
|
||
"decision {old} was already superseded by {}",
|
||
o.superseded_by.unwrap_or_default()
|
||
)));
|
||
}
|
||
Some(_) => {}
|
||
}
|
||
}
|
||
Ok((self.conventions.next_decision_id(), d))
|
||
}
|
||
|
||
/// Apply a journaled `decision.recorded` under `id` at `seq`.
|
||
pub fn apply_decision(
|
||
&mut self,
|
||
id: u64,
|
||
d: crate::conventions::NewDecision,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.conventions
|
||
.apply_decision(id, d, seq)
|
||
.map_err(LedgerError::InvalidConvention)
|
||
}
|
||
|
||
/// Check an open item before it is journaled; returns it, under the
|
||
/// id it will get.
|
||
pub fn check_open_item(
|
||
&self,
|
||
i: &crate::conventions::NewOpenItem,
|
||
) -> Result<(u64, crate::conventions::NewOpenItem), LedgerError> {
|
||
let i = crate::conventions::check_open_item(i).map_err(LedgerError::InvalidConvention)?;
|
||
Ok((self.conventions.next_open_item_id(), i))
|
||
}
|
||
|
||
/// Apply a journaled `open_item.raised` under `id` at `seq`.
|
||
pub fn apply_open_item(
|
||
&mut self,
|
||
id: u64,
|
||
i: crate::conventions::NewOpenItem,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.conventions
|
||
.apply_raise(id, i, seq)
|
||
.map_err(LedgerError::InvalidConvention)
|
||
}
|
||
|
||
/// Check a resolution before it is journaled: the item is open, the
|
||
/// text and source are sound.
|
||
pub fn check_resolve_item(
|
||
&self,
|
||
id: u64,
|
||
resolution: &str,
|
||
source: &crate::settings::Source,
|
||
) -> Result<String, LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
source.check().map_err(bad)?;
|
||
let text =
|
||
crate::settings::long_text("resolution", resolution, crate::settings::MAX_LONG_TEXT)
|
||
.map_err(bad)?;
|
||
match self.conventions.open_item(id) {
|
||
None => Err(bad(format!(
|
||
"unknown open item {id}: books.open_items lists them"
|
||
))),
|
||
Some(i) if i.resolved.is_some() => {
|
||
Err(bad(format!("open item {id} is already resolved")))
|
||
}
|
||
Some(_) => Ok(text),
|
||
}
|
||
}
|
||
|
||
/// Apply a journaled `open_item.resolved` at `seq`.
|
||
pub fn apply_resolve_item(
|
||
&mut self,
|
||
id: u64,
|
||
resolution: String,
|
||
source: crate::settings::Source,
|
||
seq: u64,
|
||
) -> Result<(), LedgerError> {
|
||
self.conventions
|
||
.apply_resolve(id, resolution, source, seq)
|
||
.map_err(LedgerError::InvalidConvention)
|
||
}
|
||
|
||
/// Check an approval before it is journaled: `entry` is a draft, the
|
||
/// approver is named, the note and source are sound. Returns the
|
||
/// approval as it will be journaled (its fingerprint is the draft's
|
||
/// now: crate::conventions INVARIANT 3).
|
||
pub fn check_approval(
|
||
&self,
|
||
entry: EntryId,
|
||
approver: &str,
|
||
note: Option<&str>,
|
||
source: &crate::settings::Source,
|
||
) -> Result<crate::conventions::Approval, LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
source.check().map_err(bad)?;
|
||
let e = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
if e.status() == EntryStatus::Posted {
|
||
return Err(LedgerError::EntryAlreadyPosted(entry));
|
||
}
|
||
Ok(crate::conventions::Approval {
|
||
approver: crate::settings::line_text("approver", approver, crate::settings::MAX_TEXT)
|
||
.map_err(bad)?,
|
||
fingerprint: self.approval_fingerprint(e),
|
||
note: note
|
||
.map(|n| crate::settings::long_text("note", n, crate::settings::MAX_LONG_TEXT))
|
||
.transpose()
|
||
.map_err(bad)?,
|
||
source: source.clone(),
|
||
seq: 0,
|
||
})
|
||
}
|
||
|
||
/// Apply a journaled `entry.approved` at `seq` (the entry must exist).
|
||
pub fn apply_approval(
|
||
&mut self,
|
||
entry: EntryId,
|
||
approval: crate::conventions::Approval,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.entries.contains_key(&entry) {
|
||
return Err(LedgerError::UnknownEntry(entry));
|
||
}
|
||
self.conventions.apply_approval(entry, approval);
|
||
Ok(())
|
||
}
|
||
|
||
/// An entry's fingerprint for approvals, with who it is with
|
||
/// (crate::conventions::fingerprint).
|
||
fn approval_fingerprint(&self, e: &JournalEntry) -> String {
|
||
crate::conventions::fingerprint(
|
||
e,
|
||
self.entry_parties
|
||
.get(&e.id())
|
||
.map_or(&[][..], Vec::as_slice),
|
||
self.entry_no_party.get(&e.id()).map(String::as_str),
|
||
)
|
||
}
|
||
|
||
/// The approvals `entry` still needs before it posts: each live
|
||
/// approval rule covering it whose approver hasn't approved it as it
|
||
/// stands, as `(rule id, approver)`. Empty under
|
||
/// `policy.approval_rules = off` (crate::conventions INVARIANT 3).
|
||
pub fn approvals_missing(&self, entry: EntryId) -> Result<Vec<(String, String)>, LedgerError> {
|
||
if self.settings.approval_rules() == crate::settings::ApprovalMode::Off {
|
||
return Ok(Vec::new());
|
||
}
|
||
let e = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||
let codes: Vec<&str> = e
|
||
.lines()
|
||
.iter()
|
||
.filter_map(|l| self.chart.get(l.account).map(|a| a.code.as_str()))
|
||
.collect();
|
||
let debits = e
|
||
.lines()
|
||
.iter()
|
||
.filter(|l| l.side == Side::Debit)
|
||
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
|
||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {entry}'s debits")))?;
|
||
let approvals = self.conventions.approvals(entry);
|
||
let key = crate::parties::name_key;
|
||
let mut missing = Vec::new();
|
||
for (c, r) in self.conventions.approval_rules() {
|
||
// A rule this version can't read covers everything (fail
|
||
// closed): no approver it names can be matched.
|
||
let Some(r) = r else {
|
||
missing.push((c.id.clone(), "an approver this version can't read".into()));
|
||
continue;
|
||
};
|
||
let by_account =
|
||
r.accounts.is_empty() || r.accounts.iter().any(|a| codes.contains(&a.as_str()));
|
||
let by_amount = r
|
||
.amount_over
|
||
.as_deref()
|
||
.and_then(crate::settings::decimal)
|
||
.is_none_or(|over| debits > over);
|
||
let covered = by_account && by_amount;
|
||
let now = self.approval_fingerprint(e);
|
||
let approved = approvals
|
||
.iter()
|
||
.any(|a| key(&a.approver) == key(&r.approver) && a.fingerprint == now);
|
||
if covered && !approved {
|
||
missing.push((c.id.clone(), r.approver.clone()));
|
||
}
|
||
}
|
||
Ok(missing)
|
||
}
|
||
|
||
/// The lines a posting template drafts for `amount` (crate::
|
||
/// conventions INVARIANT 2): `(account, side, amount)`, each side
|
||
/// summing to `amount` exactly, and the template's description.
|
||
/// `slots` names the account (by code) for each of the template's
|
||
/// slots (`{expense}` → `"expense"`): every slot, and nothing else.
|
||
pub fn template_lines(
|
||
&self,
|
||
template_id: &str,
|
||
amount: Decimal,
|
||
slots: &std::collections::BTreeMap<String, String>,
|
||
) -> Result<TemplateDraft, LedgerError> {
|
||
let bad = LedgerError::InvalidConvention;
|
||
let c = self.conventions.convention(template_id).ok_or_else(|| {
|
||
bad(format!(
|
||
"unknown convention {template_id:?}: books.conventions lists the templates"
|
||
))
|
||
})?;
|
||
if c.retired.is_some() {
|
||
return Err(bad(format!("template {template_id} is retired")));
|
||
}
|
||
let t = c.template().ok_or_else(|| {
|
||
bad(format!(
|
||
"convention {template_id} is a {}, not a posting template",
|
||
c.kind.key()
|
||
))
|
||
})?;
|
||
let wanted: std::collections::BTreeSet<&str> = t
|
||
.lines
|
||
.iter()
|
||
.filter_map(|l| crate::conventions::slot(&l.account))
|
||
.collect();
|
||
let missing: Vec<&str> = wanted
|
||
.iter()
|
||
.copied()
|
||
.filter(|w| !slots.contains_key(*w))
|
||
.collect();
|
||
if !missing.is_empty() {
|
||
return Err(bad(format!(
|
||
"template {template_id} needs an account for {}: pass accounts {{{}}}",
|
||
missing.join(", "),
|
||
missing
|
||
.iter()
|
||
.map(|m| format!("\"{m}\": \"<code>\""))
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
)));
|
||
}
|
||
if let Some(extra) = slots.keys().find(|k| !wanted.contains(k.as_str())) {
|
||
return Err(bad(format!(
|
||
"template {template_id} has no slot {extra:?}{}",
|
||
if wanted.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(
|
||
"; its slots: {}",
|
||
wanted.iter().copied().collect::<Vec<_>>().join(", ")
|
||
)
|
||
}
|
||
)));
|
||
}
|
||
if amount <= Decimal::ZERO {
|
||
return Err(bad(format!("amount {amount} must be above zero")));
|
||
}
|
||
if amount > crate::kernel::MAX_LINE_AMOUNT {
|
||
return Err(LedgerError::AmountOutOfRange);
|
||
}
|
||
check_minor_units(Money::new(amount, self.base_currency))?;
|
||
let places = self.base_currency.minor_units();
|
||
let mut out = Vec::new();
|
||
for side in [Side::Debit, Side::Credit] {
|
||
let lines: Vec<_> = t.lines.iter().filter(|l| l.side == side).collect();
|
||
let shares: Vec<Decimal> = lines
|
||
.iter()
|
||
.map(|l| crate::settings::decimal(&l.share).unwrap_or_default())
|
||
.collect();
|
||
let parts = crate::conventions::split(amount, &shares, places).ok_or_else(|| {
|
||
LedgerError::TooLargeToCompute(format!("template {template_id} at {amount}"))
|
||
})?;
|
||
for (l, part) in lines.iter().zip(parts) {
|
||
if part <= Decimal::ZERO {
|
||
return Err(bad(format!(
|
||
"{amount} is too small to split by template {template_id}: account {}'s part would be {part}",
|
||
l.account
|
||
)));
|
||
}
|
||
let code = match crate::conventions::slot(&l.account) {
|
||
Some(name) => slots[name].as_str(),
|
||
None => l.account.as_str(),
|
||
};
|
||
let a = self
|
||
.chart
|
||
.find_by_code(code)
|
||
.ok_or_else(|| bad(format!("template account {code} is not in the chart")))?;
|
||
if a.placeholder {
|
||
return Err(bad(format!(
|
||
"account {code} is a placeholder (a heading); a template posts to accounts that take postings"
|
||
)));
|
||
}
|
||
out.push((a.id, side, Money::new(part, self.base_currency)));
|
||
}
|
||
}
|
||
Ok((t.description, out))
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Classes
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Add a class (division) for line-level P&L tracking: a project with
|
||
/// only its name (rules.md B-37). Returns its id.
|
||
///
|
||
/// ```text
|
||
/// GOAL: a new class under a name no other class has.
|
||
/// SOURCE: docs/spec/rules.md B-37 item 1.
|
||
/// SCENARIOS:
|
||
/// 1. IF the name is blank: SHOULD refuse (EmptyClassName).
|
||
/// 2. IF it is longer than a line, holds a control character or a
|
||
/// full TIN: SHOULD refuse.
|
||
/// 3. IF another class has it, ignoring capitals and surrounding
|
||
/// space: SHOULD refuse (DuplicateClassName). Replay never checks
|
||
/// (replay_class), so old books holding a duplicate still open.
|
||
/// 4. IF it is digits only: SHOULD refuse (it would read as an id).
|
||
/// ```
|
||
pub fn add_class(&mut self, name: &str) -> Result<ClassId, LedgerError> {
|
||
let name = self.check_class_name(name, None)?;
|
||
let id = self.next_class_id;
|
||
self.next_class_id += 1;
|
||
self.classes.insert(id, Class { id, name });
|
||
Ok(id)
|
||
}
|
||
|
||
/// A class name, trimmed, if a class (other than `except`) may take it.
|
||
///
|
||
/// ```text
|
||
/// GOAL: one rule for every name a command gives a class.
|
||
/// SOURCE: docs/spec/rules.md B-37 item 1; B-26 (no full TIN).
|
||
/// SCENARIOS:
|
||
/// 1. IF `except` is a class and the name is its own, exactly: SHOULD
|
||
/// keep it as stored, unchecked, so a fact-only change never
|
||
/// renames (an earlier version stored names untrimmed) and never
|
||
/// refuses a name the books already hold.
|
||
/// 2. IF it is the class's own name up to capitals and surrounding
|
||
/// space: SHOULD check its text but not its uniqueness, so old
|
||
/// books holding a duplicate (replay never checks) can still
|
||
/// change either class.
|
||
/// 3. IF it is digits only: SHOULD refuse, since a project is named
|
||
/// by id or name and such a name would read as an id.
|
||
/// 4. IF another class has it, ignoring capitals and surrounding
|
||
/// space: SHOULD refuse (DuplicateClassName).
|
||
/// ```
|
||
fn check_class_name(&self, name: &str, except: Option<ClassId>) -> Result<String, LedgerError> {
|
||
let current = except.and_then(|id| self.classes.get(&id));
|
||
if let Some(c) = current
|
||
&& c.name == name
|
||
{
|
||
return Ok(c.name.clone());
|
||
}
|
||
if name.trim().is_empty() {
|
||
return Err(LedgerError::EmptyClassName);
|
||
}
|
||
let name = crate::settings::line_text("project name", name, crate::settings::MAX_TEXT)
|
||
.map_err(LedgerError::InvalidProject)?;
|
||
let folded = fold_class_name(&name);
|
||
if current.is_some_and(|c| fold_class_name(&c.name) == folded) {
|
||
return Ok(name);
|
||
}
|
||
if name.chars().all(|c| c.is_ascii_digit()) {
|
||
return Err(LedgerError::InvalidProject(format!(
|
||
"project name {name:?} is digits only, so it would read as a project id: add a word (\"Job {name}\")"
|
||
)));
|
||
}
|
||
if self
|
||
.classes
|
||
.values()
|
||
.any(|c| Some(c.id) != except && fold_class_name(&c.name) == folded)
|
||
{
|
||
return Err(LedgerError::DuplicateClassName(name));
|
||
}
|
||
Ok(name)
|
||
}
|
||
|
||
/// Look up a class.
|
||
pub fn get_class(&self, id: ClassId) -> Option<&Class> {
|
||
self.classes.get(&id)
|
||
}
|
||
|
||
/// All classes, in id order.
|
||
pub fn list_classes(&self) -> Vec<&Class> {
|
||
let mut ids: Vec<ClassId> = self.classes.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.classes.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// Find a class by name, ignoring capitals (in any script) and
|
||
/// surrounding space: the fold the duplicate check uses. Among old
|
||
/// books' duplicates (replay never checks), the lowest id.
|
||
pub fn find_class_by_name(&self, name: &str) -> Option<&Class> {
|
||
let folded = fold_class_name(name);
|
||
self.classes
|
||
.values()
|
||
.filter(|c| fold_class_name(&c.name) == folded)
|
||
.min_by_key(|c| c.id)
|
||
}
|
||
|
||
/// Tag a draft entry's line with a class, or clear it with `None`.
|
||
/// Draft-only: posted entries are immutable, so their classes are
|
||
/// frozen at posting time.
|
||
///
|
||
/// ```text
|
||
/// GOAL: tag a draft line with a class (or clear it).
|
||
/// SOURCE: R-? Tracking classes.
|
||
/// SCENARIOS:
|
||
/// 1. IF the entry is posted:
|
||
/// - SHOULD refuse
|
||
/// - NEVER change a posted line (B-8)
|
||
/// 2. IF the line or class doesn't exist: SHOULD refuse.
|
||
/// ```
|
||
pub fn set_line_class(
|
||
&mut self,
|
||
entry: EntryId,
|
||
line_index: usize,
|
||
class_id: Option<ClassId>,
|
||
) -> Result<(), LedgerError> {
|
||
if let Some(cid) = class_id
|
||
&& !self.classes.contains_key(&cid)
|
||
{
|
||
return Err(LedgerError::UnknownClass(cid));
|
||
}
|
||
let e = self.draft_mut(entry)?;
|
||
if line_index >= e.lines().len() {
|
||
return Err(LedgerError::LineIndexOutOfRange(line_index, entry));
|
||
}
|
||
e.set_line_class(line_index, class_id);
|
||
Ok(())
|
||
}
|
||
|
||
/// Record a class without allocating: replay support for journaling
|
||
/// stores. The id must be the next class id — anything else means the
|
||
/// journal and the allocator disagree.
|
||
#[doc(hidden)]
|
||
pub fn replay_class(&mut self, id: ClassId, name: String) -> Result<(), LedgerError> {
|
||
if id != self.next_class_id {
|
||
return Err(LedgerError::ReplayIdMismatch(
|
||
id as u64,
|
||
self.next_class_id as u64,
|
||
));
|
||
}
|
||
self.next_class_id += 1;
|
||
self.classes.insert(id, Class { id, name });
|
||
Ok(())
|
||
}
|
||
|
||
/// A class's project facts: the ones set, or the defaults for a class
|
||
/// that has none (every class an earlier version wrote). `None` for no
|
||
/// such class.
|
||
pub fn project_facts(&self, id: ClassId) -> Option<crate::classes::ProjectFacts> {
|
||
self.classes.get(&id)?;
|
||
Some(self.projects.get(&id).cloned().unwrap_or_default())
|
||
}
|
||
|
||
/// The projects for or with a party, by name (ignoring capitals).
|
||
pub fn projects_of_party(&self, party: crate::PartyId) -> Vec<ClassId> {
|
||
let mut ids: Vec<ClassId> = self
|
||
.projects
|
||
.iter()
|
||
.filter(|(_, f)| f.party == Some(party))
|
||
.map(|(id, _)| *id)
|
||
.filter(|id| self.classes.contains_key(id))
|
||
.collect();
|
||
ids.sort_by_key(|id| {
|
||
let c = &self.classes[id];
|
||
(c.name.to_lowercase(), c.id)
|
||
});
|
||
ids
|
||
}
|
||
|
||
/// Check a whole project record before it is journaled: returns the
|
||
/// trimmed name and the normalized facts. Changes nothing.
|
||
///
|
||
/// ```text
|
||
/// GOAL: refuse any project record the books can't take, before a
|
||
/// line is written.
|
||
/// SOURCE: docs/spec/rules.md B-37 item 1 (and B-26: no full TIN).
|
||
/// SCENARIOS:
|
||
/// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass).
|
||
/// 2. IF the name is blank, too long, or another class has it
|
||
/// (ignoring capitals; the class itself excepted): SHOULD refuse.
|
||
/// 3. IF a fact breaks its rule (ProjectFacts::check): SHOULD refuse.
|
||
/// 4. IF the party is not in the directory: SHOULD refuse.
|
||
/// ```
|
||
pub fn check_set_project(
|
||
&self,
|
||
id: ClassId,
|
||
name: &str,
|
||
facts: &crate::classes::ProjectFacts,
|
||
) -> Result<(String, crate::classes::ProjectFacts), LedgerError> {
|
||
if !self.classes.contains_key(&id) {
|
||
return Err(LedgerError::UnknownClass(id));
|
||
}
|
||
let name = self.check_class_name(name, Some(id))?;
|
||
let facts = facts.check().map_err(LedgerError::InvalidProject)?;
|
||
if let Some(p) = facts.party
|
||
&& self.get_party(p).is_none()
|
||
{
|
||
return Err(LedgerError::InvalidProject(format!(
|
||
"party {p} is not in the directory: books.parties lists them"
|
||
)));
|
||
}
|
||
Ok((name, facts))
|
||
}
|
||
|
||
/// Apply a project record after [`Ledger::check_set_project`]: rename
|
||
/// the class, keep the facts.
|
||
pub fn set_project(
|
||
&mut self,
|
||
id: ClassId,
|
||
name: &str,
|
||
facts: crate::classes::ProjectFacts,
|
||
) -> Result<(), LedgerError> {
|
||
let (name, facts) = self.check_set_project(id, name, &facts)?;
|
||
self.put_project(id, name, facts);
|
||
Ok(())
|
||
}
|
||
|
||
/// Replay a journaled project record: only what replay can't do
|
||
/// without (the class and the party exist), never the text rules, so a
|
||
/// rule tightened later never breaks old books (as
|
||
/// `AssetBasis::check_journaled`, rules.md B-25).
|
||
#[doc(hidden)]
|
||
pub fn replay_set_project(
|
||
&mut self,
|
||
id: ClassId,
|
||
name: String,
|
||
facts: crate::classes::ProjectFacts,
|
||
) -> Result<(), LedgerError> {
|
||
if !self.classes.contains_key(&id) {
|
||
return Err(LedgerError::UnknownClass(id));
|
||
}
|
||
if let Some(p) = facts.party
|
||
&& self.get_party(p).is_none()
|
||
{
|
||
return Err(LedgerError::InvalidProject(format!(
|
||
"party {p} is not in the directory"
|
||
)));
|
||
}
|
||
self.put_project(id, name, facts);
|
||
Ok(())
|
||
}
|
||
|
||
fn put_project(&mut self, id: ClassId, name: String, facts: crate::classes::ProjectFacts) {
|
||
if let Some(c) = self.classes.get_mut(&id) {
|
||
c.name = name;
|
||
}
|
||
if facts.is_default() {
|
||
self.projects.remove(&id);
|
||
} else {
|
||
self.projects.insert(id, facts);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A class name as names compare (rules.md B-37 item 1): trimmed, in
|
||
/// lower case (Unicode's, so `Éclair` and `éCLAIR` are one name).
|
||
fn fold_class_name(name: &str) -> String {
|
||
name.trim().to_lowercase()
|
||
}
|
||
|
||
/// Refuse an amount finer than its currency's minor unit (rules.md B-3).
|
||
/// A currency with no defined minor unit (ISO 4217 "N.A.") is not checked.
|
||
pub(crate) fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
|
||
match amount.currency.minor_units() {
|
||
Some(places) if amount.amount.normalize().scale() > places => {
|
||
Err(LedgerError::FinerThanMinorUnit {
|
||
amount: amount.amount,
|
||
currency: amount.currency.code().to_string(),
|
||
places,
|
||
})
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// Refuse a new line larger than any posting could carry
|
||
/// (kernel::MAX_LINE_AMOUNT; P0 review round 3, S1). Only the size: the
|
||
/// minor unit is check_minor_units', and the post checks the full range.
|
||
fn check_line_size(amount: Decimal) -> Result<(), LedgerError> {
|
||
if amount.abs() > kernel::MAX_LINE_AMOUNT {
|
||
return Err(LedgerError::AmountOutOfRange);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A members close's (capital account, share) list, in order: what a run
|
||
/// of run-rule closes must share in that order, and a count of cumulative
|
||
/// closes as a set ([`same_member_set`]).
|
||
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
|
||
members.iter().map(|m| (m.capital, m.share)).collect()
|
||
}
|
||
|
||
/// Two members closes' (capital account, share) lists name the same
|
||
/// members with the same shares, in any order (rules.md B-13: a reorder of
|
||
/// the members is not a change of members). Shares compare by value, so
|
||
/// 0.5 is 0.50.
|
||
fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool {
|
||
let sorted = |v: &[(AccountId, Decimal)]| {
|
||
let mut v = v.to_vec();
|
||
v.sort_unstable();
|
||
v
|
||
};
|
||
a.len() == b.len() && sorted(a) == sorted(b)
|
||
}
|
||
|
||
/// A vendor invoice number as the duplicate check compares it: lower case,
|
||
/// with spaces and hyphens removed ("ACME-123" and "acme 123" are the
|
||
/// same number; rules.md B-40).
|
||
fn normalize_vendor_ref(vendor_ref: &str) -> String {
|
||
vendor_ref
|
||
.chars()
|
||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||
.flat_map(char::to_lowercase)
|
||
.collect()
|
||
}
|
||
|
||
/// A members regime ([`Ledger::members_regime`]): the members closes with
|
||
/// one set of members and shares since the last close with another.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct Regime {
|
||
/// The close the count starts at (its [`Period::close_seq`]); `None`
|
||
/// when no members close with this set is counted yet.
|
||
pub since_seq: Option<u64>,
|
||
/// The net those closes closed, together (negative for a loss).
|
||
pub closed: Decimal,
|
||
/// What those closes credited each member, in the order asked for, by
|
||
/// capital account.
|
||
pub given: Vec<Decimal>,
|
||
/// The members as the count's first close listed them: the order its
|
||
/// tied odd units go in. As of a day before any close of a count, the
|
||
/// count the next members close begins, when it is these members'
|
||
/// ([`Ledger::members_regime`]). Empty when there is none.
|
||
pub order: Vec<(AccountId, Decimal)>,
|
||
}
|
||
|
||
impl Regime {
|
||
/// [`periods::allocate_cumulative`] of `total` among `members` against
|
||
/// `given` (both in `members`' order), the tied odd units to the member
|
||
/// the count's first close listed first ([`Regime::order`]; `members`'
|
||
/// own order when no close has begun the count), the parts back in `members`'
|
||
/// order. So a count's ties go to the same member whatever order later
|
||
/// closes, statements or the settings list the members in, and a
|
||
/// reorder of the members moves no cent (rules.md B-13, D-38).
|
||
pub(crate) fn allocate(
|
||
&self,
|
||
members: &[(AccountId, Decimal)],
|
||
total: Decimal,
|
||
given: &[Decimal],
|
||
places: u32,
|
||
) -> Result<Vec<Decimal>, LedgerError> {
|
||
let order = if self.order.is_empty() {
|
||
members
|
||
} else {
|
||
&self.order
|
||
};
|
||
let index: Vec<usize> = order
|
||
.iter()
|
||
.map(|(capital, _)| {
|
||
members
|
||
.iter()
|
||
.position(|(c, _)| c == capital)
|
||
.expect("a count's members are the members asked for")
|
||
})
|
||
.collect();
|
||
let shares: Vec<Decimal> = index.iter().map(|&i| members[i].1).collect();
|
||
let given: Vec<Decimal> = index.iter().map(|&i| given[i]).collect();
|
||
let split = periods::allocate_cumulative(total, &shares, &given, places)?;
|
||
let mut parts = vec![Decimal::ZERO; members.len()];
|
||
for (&i, part) in index.iter().zip(split) {
|
||
parts[i] = part;
|
||
}
|
||
Ok(parts)
|
||
}
|
||
}
|