rustledger_validate/validators/balance.rs
1//! Balance and pad validation.
2
3// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
4use rust_decimal::{Decimal, MathematicalOps};
5use rustledger_core::{Amount, Balance, Pad, Position};
6
7use super::helpers::require_account_open;
8use crate::error::{ErrorCode, ValidationError};
9use crate::{LedgerState, PendingPad};
10
11/// Multiplier for balance assertion tolerance (matches Python beancount).
12/// Balance assertions use 2x the `tolerance_multiplier` option.
13const BALANCE_TOLERANCE_MULTIPLIER: Decimal = Decimal::TWO;
14
15/// Compute the tolerance to apply when comparing a balance assertion's
16/// expected amount against the booked actual.
17///
18/// - `expected`: the asserted amount from the balance directive.
19/// - `explicit`: the `~ tolerance` from the directive, if any (always
20/// wins).
21/// - `tolerance_multiplier`: the active `inferred_tolerance_multiplier`
22/// option (default 0.5; overridable via `option
23/// "inferred_tolerance_multiplier" "..."`).
24///
25/// Mirrors the inline logic in `validate_balance_late` (private) so
26/// out-of-pipeline consumers (currently the LSP code-lens path)
27/// produce the same verdict as the validator without re-deriving the
28/// rule from the Beancount spec.
29///
30/// Matches Python beancount:
31/// <https://github.com/beancount/beancount/blob/master/beancount/ops/balance.py>
32#[must_use]
33pub fn balance_tolerance(
34 expected: Decimal,
35 explicit: Option<Decimal>,
36 tolerance_multiplier: Decimal,
37) -> Decimal {
38 if let Some(t) = explicit {
39 return t;
40 }
41 let scale = expected.scale();
42 if scale > 0 {
43 let quantum = DECIMAL_TEN.powi(-i64::from(scale));
44 tolerance_multiplier * BALANCE_TOLERANCE_MULTIPLIER * quantum
45 } else {
46 Decimal::ZERO
47 }
48}
49
50/// Base 10 for tolerance scale calculation.
51const DECIMAL_TEN: Decimal = Decimal::TEN;
52
53/// Validate a Pad directive.
54pub fn validate_pad(
55 state: &mut LedgerState,
56 pad: &Pad,
57 location: Option<(rustledger_parser::Span, u16)>,
58 errors: &mut Vec<ValidationError>,
59) {
60 // Check that the target account exists
61 if !require_account_open(state, &pad.account, pad.date, "Pad target account", errors) {
62 return;
63 }
64
65 // Check that the source account exists
66 if !require_account_open(
67 state,
68 &pad.source_account,
69 pad.date,
70 "Pad source account",
71 errors,
72 ) {
73 return;
74 }
75
76 // Add to pending pads list for this account
77 let pending_pad = PendingPad {
78 source_account: pad.source_account.clone(),
79 date: pad.date,
80 padded_currencies: rustc_hash::FxHashSet::default(),
81 location,
82 };
83 state
84 .pending_pads
85 .entry(pad.account.clone())
86 .or_default()
87 .push(pending_pad);
88}
89
90/// Early-phase balance validation — runs on pre-booking directives.
91///
92/// Only checks account presence (E1001). The actual-vs-asserted
93/// comparison is deferred to the late phase, since it depends on the
94/// inventory state that booking + the late-phase transaction validator
95/// build up.
96pub fn validate_balance_early(
97 state: &LedgerState,
98 bal: &Balance,
99 errors: &mut Vec<ValidationError>,
100) {
101 require_account_open(state, &bal.account, bal.date, "Account", errors);
102
103 // Flag a balance asserted in a currency the account doesn't allow. Without
104 // this, the only signal is a confusing "Balance failed ... got 0 EUR" (the
105 // account simply holds no EUR); beancount emits a dedicated "invalid
106 // currency for Balance" diagnostic. Only when the account constrains its
107 // currencies (empty set = any currency allowed), matching the transaction
108 // currency check — #1668.
109 if let Some(account_state) = state.accounts.get(&bal.account)
110 && !account_state.currencies.is_empty()
111 && !account_state.currencies.contains(&bal.amount.currency)
112 {
113 let mut allowed: Vec<String> = account_state
114 .currencies
115 .iter()
116 .map(std::string::ToString::to_string)
117 .collect();
118 allowed.sort();
119 errors.push(ValidationError::new(
120 ErrorCode::CurrencyNotAllowed,
121 format!(
122 "Invalid currency {} for Balance directive on {} (account holds {})",
123 bal.amount.currency,
124 bal.account,
125 allowed.join(", ")
126 ),
127 bal.date,
128 ));
129 }
130}
131
132/// Late-phase balance validation — runs after booking + plugins.
133///
134/// Applies pending pads if any (E2004 multi-pad warning), then compares
135/// the asserted balance against the accumulated inventory state.
136pub fn validate_balance_late(
137 state: &mut LedgerState,
138 bal: &Balance,
139 errors: &mut Vec<ValidationError>,
140) {
141 // The early phase already verified the account exists. If somehow
142 // it disappeared between phases (it shouldn't), bail out quietly —
143 // the early error is already in the report.
144 if !state.accounts.contains_key(&bal.account) {
145 return;
146 }
147
148 // Check if there are pending pads for this account
149 // Use get_mut instead of remove - a pad can apply to multiple currencies
150 if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
151 // Drop pads that have already served a balance in THIS specific
152 // currency. A single Pad can still serve multiple
153 // currency-specific Balance assertions on the same target —
154 // we only remove pads that have nothing left to offer for the
155 // currency being asserted right now. Without this, the vec grows
156 // for the lifetime of the session and E2003 / E2004 detection
157 // fires against pads that already served their purpose.
158 pending_pads.retain(|p| !p.padded_currencies.contains(&bal.amount.currency));
159
160 // A Pad on date D is effective for the NEXT Balance on the
161 // target account dated strictly after D (Python beancount
162 // semantics — Pad creates an entry "between" D and the next
163 // balance). Filter `pending_pads` to those whose date precedes
164 // this balance; later-dated pads are still pending for some
165 // future balance and must not be considered here. Required
166 // because the phase split pre-registers ALL pads during Early
167 // before any Balance runs in Late.
168 //
169 // The early-phase iteration sorts pads by date (see
170 // `validate_phase_inner`), so `pending_pads` is itself in
171 // date-sorted push order — `effective_idx.last()` is therefore
172 // the most recent effective pad (Python's `active_pad`).
173 let effective_idx: Vec<usize> = pending_pads
174 .iter()
175 .enumerate()
176 .filter(|(_, p)| p.date < bal.date)
177 .map(|(i, _)| i)
178 .collect();
179
180 // Check for multiple effective pads (E2004) — every effective
181 // pad is unused (retain ran above), so we just need to count.
182 if effective_idx.len() > 1 {
183 errors.push(
184 ValidationError::new(
185 ErrorCode::MultiplePadForBalance,
186 format!(
187 "Multiple pad directives for {} {} before balance assertion",
188 bal.account, bal.amount.currency
189 ),
190 bal.date,
191 )
192 .with_context(format!(
193 "pad dates: {}",
194 effective_idx
195 .iter()
196 .map(|&i| pending_pads[i].date.to_string())
197 .collect::<Vec<_>>()
198 .join(", ")
199 )),
200 );
201 }
202
203 // Use the most recent effective pad
204 if let Some(pending_pad) = effective_idx.last().and_then(|&i| pending_pads.get_mut(i)) {
205 // Apply padding: calculate difference and add to both accounts
206 // Balance assertions include sub-accounts; the prefix index answers
207 // this without scanning every account (disjoint borrow from the
208 // `pending_pad` mutable borrow held here).
209 let actual = crate::sum_account_subtree(
210 &state.inventories,
211 &state.inventory_accounts,
212 &bal.account,
213 &bal.amount.currency,
214 );
215 {
216 let expected = bal.amount.number;
217 let difference = expected - actual;
218
219 if difference != Decimal::ZERO {
220 // Add padding amount to target account
221 if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
222 target_inv.add(Position::simple(Amount::new(
223 difference,
224 &bal.amount.currency,
225 )));
226 }
227
228 // Subtract padding amount from source account
229 if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
230 {
231 source_inv.add(Position::simple(Amount::new(
232 -difference,
233 &bal.amount.currency,
234 )));
235 }
236
237 // Record that this pad covered the asserted currency.
238 pending_pad
239 .padded_currencies
240 .insert(bal.amount.currency.clone());
241 }
242 }
243 // An effective pad applied (or matched a zero difference);
244 // either way, the regular balance check below would be
245 // redundant.
246 return;
247 }
248 // No effective pad for this balance — fall through to the
249 // regular balance check so the user gets a real assertion
250 // result instead of silent skip.
251 }
252
253 // Get inventory and check balance (no padding case).
254 // In beancount, balance assertions include sub-accounts
255 // e.g., balance Assets:Checking includes Assets:Checking:Sub1, etc.
256 let actual = crate::sum_account_subtree(
257 &state.inventories,
258 &state.inventory_accounts,
259 &bal.account,
260 &bal.amount.currency,
261 );
262
263 // Always check balance assertions, even for accounts with no transactions.
264 // This matches Python beancount behavior where `balance Account 1 USD` fails
265 // if the account has 0 USD (no transactions).
266 let expected = bal.amount.number;
267 let difference = (actual - expected).abs();
268
269 // Record the computed result so consumers can render per-assertion pass/fail
270 // without re-deriving the balance (#1663). `diff` is signed (`computed −
271 // asserted`); this is the validator's own `actual`, so the load/validate
272 // surfaces can't diverge from the check.
273 state.balance_actuals.push(crate::BalanceActual {
274 date: bal.date,
275 account: bal.account.clone(),
276 currency: bal.amount.currency.clone(),
277 diff: actual - expected,
278 });
279
280 // Determine tolerance via the shared helper so out-of-pipeline
281 // consumers (LSP code lens) and the validator stay in lockstep.
282 let is_explicit = bal.tolerance.is_some();
283 let tolerance = balance_tolerance(expected, bal.tolerance, state.options.tolerance_multiplier);
284
285 if difference > tolerance {
286 // Use E2002 for explicit tolerance, E2001 for inferred
287 let error_code = if is_explicit {
288 ErrorCode::BalanceToleranceExceeded
289 } else {
290 ErrorCode::BalanceAssertionFailed
291 };
292
293 let message = if is_explicit {
294 format!(
295 "Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
296 bal.account,
297 expected,
298 bal.amount.currency,
299 tolerance,
300 actual,
301 bal.amount.currency,
302 difference
303 )
304 } else {
305 format!(
306 "Balance failed for {}: expected {} {}, got {} {}",
307 bal.account, expected, bal.amount.currency, actual, bal.amount.currency
308 )
309 };
310
311 errors.push(
312 ValidationError::new(error_code, message, bal.date)
313 .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
314 );
315 }
316}
317
318#[cfg(test)]
319mod tolerance_tests {
320 use super::*;
321 use rust_decimal_macros::dec;
322
323 /// Default `tolerance_multiplier` from `ValidationOptions::default()`
324 /// (also the loader's default via `Options::new()`).
325 fn default_mul() -> Decimal {
326 dec!(0.5)
327 }
328
329 #[test]
330 fn explicit_tolerance_always_wins() {
331 // Even an absurdly-small / absurdly-large explicit tolerance
332 // overrides the scale-derived default. This is the
333 // contract `~ tolerance` on a Balance directive provides.
334 assert_eq!(
335 balance_tolerance(dec!(100.00), Some(dec!(0.001)), default_mul()),
336 dec!(0.001)
337 );
338 assert_eq!(
339 balance_tolerance(dec!(100.00), Some(dec!(50)), default_mul()),
340 dec!(50)
341 );
342 }
343
344 #[test]
345 fn integer_amount_requires_exact_match() {
346 // scale == 0 means the asserted amount has no decimal places.
347 // Python beancount requires an exact match in that case; the
348 // helper returns ZERO to make `difference > 0` strict.
349 assert_eq!(
350 balance_tolerance(dec!(100), None, default_mul()),
351 Decimal::ZERO
352 );
353 }
354
355 #[test]
356 fn two_decimal_amount_uses_default_quantum() {
357 // For `100.00 USD` with the default multiplier 0.5:
358 // tolerance = 0.5 * 2 * 0.01 = 0.01
359 // This is the Beancount-spec rule the LSP and the validator
360 // both depend on; if this changes, every balance assertion
361 // shifts pass/fail.
362 assert_eq!(
363 balance_tolerance(dec!(100.00), None, default_mul()),
364 dec!(0.01)
365 );
366 }
367
368 #[test]
369 fn higher_precision_scales_down() {
370 // 4 decimal places: tolerance = 0.5 * 2 * 0.0001 = 0.0001
371 assert_eq!(
372 balance_tolerance(dec!(100.0000), None, default_mul()),
373 dec!(0.0001)
374 );
375 }
376
377 #[test]
378 fn multiplier_one_doubles_default() {
379 // File overrides `option "inferred_tolerance_multiplier" "1.0"`:
380 // tolerance = 1.0 * 2 * 0.01 = 0.02
381 assert_eq!(balance_tolerance(dec!(100.00), None, dec!(1.0)), dec!(0.02));
382 }
383
384 #[test]
385 fn multiplier_zero_forces_strict_match() {
386 // `option "inferred_tolerance_multiplier" "0.0"` is the
387 // canonical way to force strict equality on a decimal
388 // amount. The helper must yield zero so the validator emits
389 // a diagnostic on any rounding drift.
390 assert_eq!(
391 balance_tolerance(dec!(100.00), None, dec!(0.0)),
392 Decimal::ZERO
393 );
394 }
395
396 #[test]
397 fn negative_amount_uses_same_scale_logic() {
398 // Tolerance is sign-independent (the validator applies it
399 // against `(actual - expected).abs()`), but the helper does
400 // not flip sign on negative expected — scale() is unsigned.
401 assert_eq!(
402 balance_tolerance(dec!(-100.00), None, default_mul()),
403 dec!(0.01)
404 );
405 }
406}