Skip to main content

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}