Skip to main content

pactole_core/
validation.rs

1//! Business validation and auto-balancing for Pactole journals.
2
3use crate::errors::ValidationError;
4use crate::models::{AccountName, Amount, CommodityName, Entry, Journal, Transaction};
5use rust_decimal::Decimal;
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8/// A complete validation result, including the normalized journal and all
9/// non-cascading issues found while walking it.
10#[derive(Debug, Clone, PartialEq)]
11pub struct ValidationReport {
12    /// Chronologically sorted journal, with inferable amounts filled in.
13    pub journal: Journal,
14    /// Issues paired with their positions in the input journal.
15    pub issues: Vec<ValidationIssue>,
16}
17
18impl ValidationReport {
19    /// Returns the validation errors in the same order as [`Self::issues`].
20    pub fn errors(&self) -> impl Iterator<Item = &ValidationError> {
21        self.issues.iter().map(|issue| &issue.error)
22    }
23}
24
25/// A validation error together with the index of the offending entry in the
26/// journal passed to [`validate_journal_all`], before chronological sorting.
27///
28/// The index is deliberately a domain-level location, not a source span:
29/// `pactole-core` must remain independent from parsers and file storage. A
30/// storage layer can use it to recover the originating file and source span.
31#[derive(Debug, Clone, PartialEq)]
32pub struct ValidationIssue {
33    /// Zero-based position in the journal supplied to validation, before the
34    /// validator's chronological sort.
35    pub input_index: usize,
36    /// Business error associated with the input entry.
37    pub error: ValidationError,
38}
39
40/// Validates a journal and returns the first business error, if any.
41///
42/// On success, returns the journal sorted chronologically and with inferable
43/// transaction amounts filled in. Use [`validate_journal_all`] to retain every
44/// non-cascading issue and its input index.
45pub fn validate_journal(journal: Journal) -> Result<Journal, ValidationError> {
46    let report = validate_journal_all(journal);
47    match report.issues.first() {
48        Some(issue) => Err(issue.error.clone()),
49        None => Ok(report.journal),
50    }
51}
52
53/// Validates every entry and returns all errors that can be diagnosed without
54/// aborting the journal walk. Invalid entries do not mutate validation state,
55/// preventing one error from causing a cascade of misleading later errors.
56///
57/// The returned journal is chronologically sorted with stable ordering for
58/// equal dates. Payee declarations, which have no date, are available before
59/// dated entries. Validation checks account lifecycles, declarations,
60/// transaction balancing and balance assertions; at most one missing posting
61/// amount is inferred when its commodity is unambiguous.
62pub fn validate_journal_all(journal: Journal) -> ValidationReport {
63    let mut indexed_entries: Vec<(usize, Entry)> =
64        journal.entries.into_iter().enumerate().collect();
65    indexed_entries.sort_by_key(|(_, entry)| entry.date());
66
67    // Maps each open account to the (possibly empty) list of commodities
68    // it is restricted to, as declared on its `open` directive. An empty
69    // list means the account is unrestricted.
70    let mut open_accounts: HashMap<AccountName, Vec<CommodityName>> = HashMap::new();
71    let mut declared_commodities: HashSet<CommodityName> = HashSet::new();
72    let mut declared_payees: HashSet<String> = HashSet::new();
73    // Running balance, per (account, commodity) pair, of every posting
74    // seen so far, used to check `balance` assertions.
75    let mut running_balances: HashMap<(AccountName, CommodityName), Decimal> = HashMap::new();
76    let mut issues = Vec::new();
77
78    for (input_index, entry) in &mut indexed_entries {
79        match entry {
80            Entry::Open(open) => {
81                if open_accounts.contains_key(&open.account) {
82                    let error = ValidationError::AccountAlreadyOpen {
83                        account: open.account.clone(),
84                        date: open.date,
85                    };
86                    issues.push(ValidationIssue {
87                        input_index: *input_index,
88                        error,
89                    });
90                    continue;
91                }
92                open_accounts.insert(open.account.clone(), open.commodities.clone());
93            }
94            Entry::Close(close) => {
95                if open_accounts.remove(&close.account).is_none() {
96                    let error = ValidationError::AccountNotOpen {
97                        account: close.account.clone(),
98                        date: close.date,
99                    };
100                    issues.push(ValidationIssue {
101                        input_index: *input_index,
102                        error,
103                    });
104                    continue;
105                }
106            }
107            Entry::Commodity(commodity) => {
108                if !declared_commodities.insert(commodity.name.clone()) {
109                    let error = ValidationError::CommodityAlreadyDeclared {
110                        commodity: commodity.name.clone(),
111                        date: commodity.date,
112                    };
113                    issues.push(ValidationIssue {
114                        input_index: *input_index,
115                        error,
116                    });
117                    continue;
118                }
119            }
120            Entry::Payee(payee) => {
121                if !declared_payees.insert(payee.name.clone()) {
122                    let error = ValidationError::PayeeAlreadyDeclared {
123                        payee: payee.name.clone(),
124                    };
125                    issues.push(ValidationIssue {
126                        input_index: *input_index,
127                        error,
128                    });
129                    continue;
130                }
131            }
132            Entry::Balance(balance) => {
133                let Some(allowed_commodities) = open_accounts.get(&balance.account) else {
134                    let error = ValidationError::AccountNotOpen {
135                        account: balance.account.clone(),
136                        date: balance.date,
137                    };
138                    issues.push(ValidationIssue {
139                        input_index: *input_index,
140                        error,
141                    });
142                    continue;
143                };
144
145                if !declared_commodities.contains(&balance.amount.commodity) {
146                    let error = ValidationError::CommodityNotDeclared {
147                        commodity: balance.amount.commodity.clone(),
148                        date: balance.date,
149                    };
150                    issues.push(ValidationIssue {
151                        input_index: *input_index,
152                        error,
153                    });
154                    continue;
155                }
156
157                if !allowed_commodities.is_empty()
158                    && !allowed_commodities.contains(&balance.amount.commodity)
159                {
160                    let error = ValidationError::CommodityNotAllowed {
161                        account: balance.account.clone(),
162                        commodity: balance.amount.commodity.clone(),
163                        date: balance.date,
164                    };
165                    issues.push(ValidationIssue {
166                        input_index: *input_index,
167                        error,
168                    });
169                    continue;
170                }
171
172                let key = (balance.account.clone(), balance.amount.commodity.clone());
173                let actual = running_balances.get(&key).copied().unwrap_or(Decimal::ZERO);
174                let expected = balance.amount.number;
175                let tolerance = balance.tolerance.unwrap_or(Decimal::ZERO);
176                if (actual - expected).abs() > tolerance {
177                    let error = ValidationError::BalanceAssertionFailed {
178                        account: balance.account.clone(),
179                        commodity: balance.amount.commodity.clone(),
180                        date: balance.date,
181                        expected,
182                        actual,
183                        tolerance,
184                    };
185                    issues.push(ValidationIssue {
186                        input_index: *input_index,
187                        error,
188                    });
189                    continue;
190                }
191            }
192            Entry::Transaction(transaction) => {
193                let transaction_errors = validate_transaction(
194                    transaction,
195                    &open_accounts,
196                    &declared_commodities,
197                    &declared_payees,
198                );
199                let has_transaction_errors = !transaction_errors.is_empty();
200                for error in transaction_errors {
201                    issues.push(ValidationIssue {
202                        input_index: *input_index,
203                        error,
204                    });
205                }
206                if has_transaction_errors {
207                    continue;
208                }
209
210                for posting in &transaction.postings {
211                    if let Some(amount) = &posting.amount {
212                        let key = (posting.account.clone(), amount.commodity.clone());
213                        *running_balances.entry(key).or_insert(Decimal::ZERO) += amount.number;
214                    }
215                }
216            }
217        }
218    }
219
220    ValidationReport {
221        journal: Journal {
222            entries: indexed_entries
223                .into_iter()
224                .map(|(_, entry)| entry)
225                .collect(),
226        },
227        issues,
228    }
229}
230
231/// Checks that every posting of `transaction` uses an open account and a
232/// declared commodity, and — when the account is restricted to a list of
233/// commodities — that it uses one of them, that its payee (when set) has
234/// been declared, then balances it.
235fn validate_transaction(
236    transaction: &mut Transaction,
237    open_accounts: &HashMap<AccountName, Vec<CommodityName>>,
238    declared_commodities: &HashSet<CommodityName>,
239    declared_payees: &HashSet<String>,
240) -> Vec<ValidationError> {
241    let mut errors = Vec::new();
242    if let Some(payee) = &transaction.payee
243        && !declared_payees.contains(payee)
244    {
245        errors.push(ValidationError::PayeeNotDeclared {
246            payee: payee.clone(),
247            date: transaction.date,
248        });
249    }
250
251    for posting in &transaction.postings {
252        if !open_accounts.contains_key(&posting.account) {
253            errors.push(ValidationError::AccountNotOpen {
254                account: posting.account.clone(),
255                date: transaction.date,
256            });
257        }
258        if let Some(amount) = &posting.amount
259            && !declared_commodities.contains(&amount.commodity)
260        {
261            errors.push(ValidationError::CommodityNotDeclared {
262                commodity: amount.commodity.clone(),
263                date: transaction.date,
264            });
265        }
266    }
267
268    if !errors.is_empty() {
269        return errors;
270    }
271
272    if let Err(error) = balance_transaction(transaction) {
273        errors.push(error);
274        return errors;
275    }
276
277    // Enforce `open`'s commodity restrictions after balancing, so that an
278    // auto-balanced posting's inferred commodity is checked too.
279    for posting in &transaction.postings {
280        let allowed = match open_accounts.get(&posting.account) {
281            Some(allowed) => allowed,
282            None => unreachable!("account presence was checked before commodity restrictions"),
283        };
284        if allowed.is_empty() {
285            continue;
286        }
287        if let Some(amount) = &posting.amount
288            && !allowed.contains(&amount.commodity)
289        {
290            errors.push(ValidationError::CommodityNotAllowed {
291                account: posting.account.clone(),
292                commodity: amount.commodity.clone(),
293                date: transaction.date,
294            });
295        }
296    }
297
298    errors
299}
300/// Validates and auto-balances a transaction's postings.
301///
302/// At most one posting may be left without an amount: if that is the
303/// case, and the other postings all share the same commodity, the
304/// missing amount is computed and filled in so that the transaction
305/// balances. If more than one posting has no amount, or the known
306/// amounts use several commodities while one is missing, an error is
307/// returned. If every posting already has an amount, the postings must
308/// sum to zero for each commodity.
309fn balance_transaction(transaction: &mut Transaction) -> Result<(), ValidationError> {
310    let missing: Vec<usize> = transaction
311        .postings
312        .iter()
313        .enumerate()
314        .filter(|(_, posting)| posting.amount.is_none())
315        .map(|(index, _)| index)
316        .collect();
317
318    if missing.len() > 1 {
319        return Err(ValidationError::TooManyPostingsWithoutAmount {
320            count: missing.len(),
321            date: transaction.date,
322        });
323    }
324
325    let mut sums: BTreeMap<&str, (CommodityName, Decimal)> = BTreeMap::new();
326    for posting in &transaction.postings {
327        if let Some(amount) = &posting.amount {
328            sums.entry(amount.commodity.as_str())
329                .or_insert_with(|| (amount.commodity.clone(), Decimal::ZERO))
330                .1 += amount.number;
331        }
332    }
333
334    if let Some(index) = missing.first().copied() {
335        if sums.len() != 1 {
336            let commodities: Vec<String> = sums.keys().map(|k| k.to_string()).collect();
337            return Err(ValidationError::AmbiguousAutoBalanceCommodity {
338                commodities,
339                date: transaction.date,
340            });
341        }
342
343        let Some((_, (commodity, sum))) = sums.into_iter().next() else {
344            unreachable!("a missing amount requires exactly one commodity sum");
345        };
346        transaction.postings[index].amount = Some(Amount {
347            number: -sum,
348            commodity,
349        });
350        return Ok(());
351    }
352
353    for (_, (commodity, sum)) in sums {
354        if !sum.is_zero() {
355            return Err(ValidationError::TransactionNotBalanced {
356                commodity: commodity.as_str().to_string(),
357                sum,
358                date: transaction.date,
359            });
360        }
361    }
362
363    Ok(())
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::models::{
370        Balance, Close, Commodity, Metadata, Open, Payee, Posting, TransactionStatus,
371    };
372
373    fn date(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
374        chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
375    }
376
377    fn posting(account: &str, amount: Option<(&str, &str)>) -> Posting {
378        Posting {
379            account: AccountName::new(account).unwrap(),
380            amount: amount.map(|(number, commodity)| Amount {
381                number: number.parse().unwrap(),
382                commodity: CommodityName::new(commodity).unwrap(),
383            }),
384            meta: Metadata::new(),
385        }
386    }
387
388    fn transaction(d: chrono::NaiveDate, postings: Vec<Posting>) -> Transaction {
389        Transaction {
390            date: d,
391            effective_date: None,
392            status: TransactionStatus::Cleared,
393            payee: None,
394            narration: None,
395            postings,
396            tags: Vec::new(),
397            links: Vec::new(),
398            reference: None,
399            meta: Metadata::new(),
400        }
401    }
402
403    fn transaction_with_payee(
404        d: chrono::NaiveDate,
405        payee: &str,
406        postings: Vec<Posting>,
407    ) -> Transaction {
408        Transaction {
409            payee: Some(payee.to_string()),
410            ..transaction(d, postings)
411        }
412    }
413
414    #[test]
415    fn validation_issue_index_refers_to_the_unsorted_input() {
416        let journal = Journal {
417            entries: vec![
418                open(date(2024, 2, 1), "Actifs:Compte"),
419                open(date(2024, 1, 1), "Actifs:Compte"),
420            ],
421        };
422
423        let report = validate_journal_all(journal);
424
425        assert_eq!(report.issues.len(), 1);
426        assert_eq!(report.issues[0].input_index, 0);
427        assert!(matches!(
428            report.issues[0].error,
429            ValidationError::AccountAlreadyOpen { .. }
430        ));
431        assert_eq!(report.journal.entries[0].date(), Some(date(2024, 1, 1)));
432        assert_eq!(report.errors().count(), 1);
433    }
434
435    fn payee(name: &str) -> Entry {
436        Entry::Payee(Payee {
437            name: name.to_string(),
438            meta: Metadata::new(),
439        })
440    }
441
442    fn open(d: chrono::NaiveDate, account: &str) -> Entry {
443        open_restricted(d, account, &[])
444    }
445
446    fn open_restricted(d: chrono::NaiveDate, account: &str, commodities: &[&str]) -> Entry {
447        Entry::Open(Open {
448            date: d,
449            account: AccountName::new(account).unwrap(),
450            commodities: commodities
451                .iter()
452                .map(|c| CommodityName::new(*c).unwrap())
453                .collect(),
454            description: None,
455            meta: Metadata::new(),
456        })
457    }
458
459    fn close(d: chrono::NaiveDate, account: &str) -> Entry {
460        Entry::Close(Close {
461            date: d,
462            account: AccountName::new(account).unwrap(),
463            meta: Metadata::new(),
464        })
465    }
466
467    fn commodity(d: chrono::NaiveDate, name: &str) -> Entry {
468        Entry::Commodity(Commodity {
469            date: d,
470            name: CommodityName::new(name).unwrap(),
471            meta: Metadata::new(),
472        })
473    }
474
475    fn balance(d: chrono::NaiveDate, account: &str, amount: (&str, &str)) -> Entry {
476        balance_with_tolerance(d, account, amount, None)
477    }
478
479    fn balance_with_tolerance(
480        d: chrono::NaiveDate,
481        account: &str,
482        amount: (&str, &str),
483        tolerance: Option<&str>,
484    ) -> Entry {
485        Entry::Balance(Balance {
486            date: d,
487            account: AccountName::new(account).unwrap(),
488            amount: Amount {
489                number: amount.0.parse().unwrap(),
490                commodity: CommodityName::new(amount.1).unwrap(),
491            },
492            tolerance: tolerance.map(|t| t.parse().unwrap()),
493            meta: Metadata::new(),
494        })
495    }
496
497    #[test]
498    fn validates_a_well_formed_journal_and_sorts_entries() {
499        let journal = Journal {
500            entries: vec![
501                Entry::Transaction(transaction(
502                    date(2024, 1, 5),
503                    vec![
504                        posting("Actifs:Compte", Some(("100.00", "EUR"))),
505                        posting("Depenses:Divers", None),
506                    ],
507                )),
508                open(date(2024, 1, 1), "Actifs:Compte"),
509                open(date(2024, 1, 1), "Depenses:Divers"),
510                commodity(date(2024, 1, 1), "EUR"),
511            ],
512        };
513
514        let validated = validate_journal(journal).unwrap();
515
516        // Entries were sorted chronologically.
517        assert_eq!(validated.entries[0].date(), Some(date(2024, 1, 1)));
518        assert_eq!(
519            validated.entries.last().unwrap().date(),
520            Some(date(2024, 1, 5))
521        );
522
523        // The transaction's missing amount was auto-filled.
524        let Entry::Transaction(txn) = validated.entries.last().unwrap() else {
525            panic!("expected a transaction");
526        };
527        assert_eq!(
528            txn.postings[1].amount,
529            Some(Amount {
530                number: "-100.00".parse().unwrap(),
531                commodity: CommodityName::new("EUR").unwrap(),
532            })
533        );
534    }
535
536    #[test]
537    fn rejects_transaction_posting_on_unopened_account() {
538        let journal = Journal {
539            entries: vec![
540                commodity(date(2024, 1, 1), "EUR"),
541                Entry::Transaction(transaction(
542                    date(2024, 1, 5),
543                    vec![
544                        posting("Actifs:Compte", Some(("100.00", "EUR"))),
545                        posting("Depenses:Divers", Some(("-100.00", "EUR"))),
546                    ],
547                )),
548            ],
549        };
550
551        assert_eq!(
552            validate_journal(journal),
553            Err(ValidationError::AccountNotOpen {
554                account: AccountName::new("Actifs:Compte").unwrap(),
555                date: date(2024, 1, 5),
556            })
557        );
558    }
559
560    #[test]
561    fn rejects_balance_assertion_on_unopened_account() {
562        let journal = Journal {
563            entries: vec![balance(date(2024, 1, 1), "Actifs:Compte", ("0.00", "EUR"))],
564        };
565
566        assert_eq!(
567            validate_journal(journal),
568            Err(ValidationError::AccountNotOpen {
569                account: AccountName::new("Actifs:Compte").unwrap(),
570                date: date(2024, 1, 1),
571            })
572        );
573    }
574
575    #[test]
576    fn rejects_closing_an_unopened_account() {
577        let journal = Journal {
578            entries: vec![close(date(2024, 1, 1), "Actifs:Compte")],
579        };
580
581        assert_eq!(
582            validate_journal(journal),
583            Err(ValidationError::AccountNotOpen {
584                account: AccountName::new("Actifs:Compte").unwrap(),
585                date: date(2024, 1, 1),
586            })
587        );
588    }
589
590    #[test]
591    fn rejects_opening_an_already_open_account() {
592        let journal = Journal {
593            entries: vec![
594                open_restricted(date(2024, 1, 1), "Actifs:Compte", &["EUR"]),
595                open_restricted(date(2024, 2, 1), "Actifs:Compte", &["USD"]),
596            ],
597        };
598
599        assert_eq!(
600            validate_journal(journal),
601            Err(ValidationError::AccountAlreadyOpen {
602                account: AccountName::new("Actifs:Compte").unwrap(),
603                date: date(2024, 2, 1),
604            })
605        );
606    }
607
608    #[test]
609    fn accepts_reopening_an_account_after_closing_it() {
610        let journal = Journal {
611            entries: vec![
612                open(date(2024, 1, 1), "Actifs:Compte"),
613                close(date(2024, 2, 1), "Actifs:Compte"),
614                open(date(2024, 3, 1), "Actifs:Compte"),
615            ],
616        };
617
618        assert!(validate_journal(journal).is_ok());
619    }
620
621    #[test]
622    fn rejects_declaring_a_commodity_twice() {
623        let journal = Journal {
624            entries: vec![
625                commodity(date(2024, 1, 1), "EUR"),
626                commodity(date(2024, 2, 1), "EUR"),
627            ],
628        };
629
630        assert_eq!(
631            validate_journal(journal),
632            Err(ValidationError::CommodityAlreadyDeclared {
633                commodity: CommodityName::new("EUR").unwrap(),
634                date: date(2024, 2, 1),
635            })
636        );
637    }
638
639    #[test]
640    fn rejects_declaring_a_payee_twice() {
641        let journal = Journal {
642            entries: vec![payee("Carrefour"), payee("Carrefour")],
643        };
644
645        assert_eq!(
646            validate_journal(journal),
647            Err(ValidationError::PayeeAlreadyDeclared {
648                payee: "Carrefour".to_string(),
649            })
650        );
651    }
652
653    #[test]
654    fn accepts_distinct_commodities_and_payees() {
655        let journal = Journal {
656            entries: vec![
657                commodity(date(2024, 1, 1), "EUR"),
658                commodity(date(2024, 1, 1), "USD"),
659                payee("Carrefour"),
660                payee("Cinema"),
661            ],
662        };
663
664        assert!(validate_journal(journal).is_ok());
665    }
666
667    #[test]
668    fn rejects_balance_assertion_using_an_undeclared_commodity() {
669        let journal = Journal {
670            entries: vec![
671                open(date(2024, 1, 1), "Actifs:Compte"),
672                balance(date(2024, 1, 5), "Actifs:Compte", ("0.00", "EUR")),
673            ],
674        };
675
676        assert_eq!(
677            validate_journal(journal),
678            Err(ValidationError::CommodityNotDeclared {
679                commodity: CommodityName::new("EUR").unwrap(),
680                date: date(2024, 1, 5),
681            })
682        );
683    }
684
685    #[test]
686    fn rejects_balance_assertion_using_a_commodity_not_allowed_by_open() {
687        let journal = Journal {
688            entries: vec![
689                open_restricted(date(2024, 1, 1), "Actifs:Compte", &["USD"]),
690                commodity(date(2024, 1, 1), "EUR"),
691                balance(date(2024, 1, 5), "Actifs:Compte", ("0.00", "EUR")),
692            ],
693        };
694
695        assert_eq!(
696            validate_journal(journal),
697            Err(ValidationError::CommodityNotAllowed {
698                account: AccountName::new("Actifs:Compte").unwrap(),
699                commodity: CommodityName::new("EUR").unwrap(),
700                date: date(2024, 1, 5),
701            })
702        );
703    }
704
705    #[test]
706    fn accepts_balance_on_account_closed_afterwards() {
707        let journal = Journal {
708            entries: vec![
709                open(date(2024, 1, 1), "Actifs:Compte"),
710                commodity(date(2024, 1, 1), "EUR"),
711                balance(date(2024, 1, 2), "Actifs:Compte", ("0.00", "EUR")),
712                close(date(2024, 1, 3), "Actifs:Compte"),
713            ],
714        };
715
716        assert!(validate_journal(journal).is_ok());
717    }
718
719    #[test]
720    fn rejects_balance_on_account_closed_before() {
721        let journal = Journal {
722            entries: vec![
723                open(date(2024, 1, 1), "Actifs:Compte"),
724                close(date(2024, 1, 2), "Actifs:Compte"),
725                balance(date(2024, 1, 3), "Actifs:Compte", ("0.00", "EUR")),
726            ],
727        };
728
729        assert_eq!(
730            validate_journal(journal),
731            Err(ValidationError::AccountNotOpen {
732                account: AccountName::new("Actifs:Compte").unwrap(),
733                date: date(2024, 1, 3),
734            })
735        );
736    }
737
738    #[test]
739    fn rejects_transaction_posting_with_undeclared_commodity() {
740        let journal = Journal {
741            entries: vec![
742                open(date(2024, 1, 1), "Actifs:Compte"),
743                open(date(2024, 1, 1), "Depenses:Divers"),
744                Entry::Transaction(transaction(
745                    date(2024, 1, 5),
746                    vec![
747                        posting("Actifs:Compte", Some(("100.00", "EUR"))),
748                        posting("Depenses:Divers", Some(("-100.00", "EUR"))),
749                    ],
750                )),
751            ],
752        };
753
754        assert_eq!(
755            validate_journal(journal),
756            Err(ValidationError::CommodityNotDeclared {
757                commodity: CommodityName::new("EUR").unwrap(),
758                date: date(2024, 1, 5),
759            })
760        );
761    }
762
763    #[test]
764    fn rejects_unbalanced_transaction() {
765        let journal = Journal {
766            entries: vec![
767                open(date(2024, 1, 1), "Actifs:Compte"),
768                open(date(2024, 1, 1), "Depenses:Divers"),
769                commodity(date(2024, 1, 1), "EUR"),
770                Entry::Transaction(transaction(
771                    date(2024, 1, 5),
772                    vec![
773                        posting("Actifs:Compte", Some(("100.00", "EUR"))),
774                        posting("Depenses:Divers", Some(("-50.00", "EUR"))),
775                    ],
776                )),
777            ],
778        };
779
780        assert_eq!(
781            validate_journal(journal),
782            Err(ValidationError::TransactionNotBalanced {
783                commodity: "EUR".to_string(),
784                sum: "50.00".parse().unwrap(),
785                date: date(2024, 1, 5),
786            })
787        );
788    }
789
790    #[test]
791    fn rejects_more_than_one_missing_amount() {
792        let journal = Journal {
793            entries: vec![
794                open(date(2024, 1, 1), "Actifs:Compte"),
795                open(date(2024, 1, 1), "Depenses:Divers"),
796                commodity(date(2024, 1, 1), "EUR"),
797                Entry::Transaction(transaction(
798                    date(2024, 1, 5),
799                    vec![
800                        posting("Actifs:Compte", None),
801                        posting("Depenses:Divers", None),
802                    ],
803                )),
804            ],
805        };
806
807        assert_eq!(
808            validate_journal(journal),
809            Err(ValidationError::TooManyPostingsWithoutAmount {
810                count: 2,
811                date: date(2024, 1, 5),
812            })
813        );
814    }
815
816    #[test]
817    fn rejects_ambiguous_commodity_for_missing_amount() {
818        let journal = Journal {
819            entries: vec![
820                open(date(2024, 1, 1), "Actifs:Compte"),
821                open(date(2024, 1, 1), "Actifs:Autre"),
822                open(date(2024, 1, 1), "Depenses:Divers"),
823                commodity(date(2024, 1, 1), "EUR"),
824                commodity(date(2024, 1, 1), "USD"),
825                Entry::Transaction(transaction(
826                    date(2024, 1, 5),
827                    vec![
828                        posting("Actifs:Compte", Some(("100.00", "EUR"))),
829                        posting("Actifs:Autre", Some(("50.00", "USD"))),
830                        posting("Depenses:Divers", None),
831                    ],
832                )),
833            ],
834        };
835
836        assert_eq!(
837            validate_journal(journal),
838            Err(ValidationError::AmbiguousAutoBalanceCommodity {
839                commodities: vec!["EUR".to_string(), "USD".to_string()],
840                date: date(2024, 1, 5),
841            })
842        );
843    }
844
845    #[test]
846    fn accepts_transaction_with_declared_payee_regardless_of_declaration_order() {
847        let journal = Journal {
848            entries: vec![
849                open(date(2024, 1, 1), "Actifs:Compte"),
850                open(date(2024, 1, 1), "Depenses:Divers"),
851                commodity(date(2024, 1, 1), "EUR"),
852                Entry::Transaction(transaction_with_payee(
853                    date(2024, 1, 5),
854                    "Carrefour",
855                    vec![
856                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
857                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
858                    ],
859                )),
860                // Declared *after* the transaction using it in the file:
861                // this must still be accepted, since a payee has no
862                // date and is not checked chronologically.
863                payee("Carrefour"),
864            ],
865        };
866
867        assert!(validate_journal(journal).is_ok());
868    }
869
870    #[test]
871    fn rejects_transaction_with_undeclared_payee() {
872        let journal = Journal {
873            entries: vec![
874                open(date(2024, 1, 1), "Actifs:Compte"),
875                open(date(2024, 1, 1), "Depenses:Divers"),
876                commodity(date(2024, 1, 1), "EUR"),
877                Entry::Transaction(transaction_with_payee(
878                    date(2024, 1, 5),
879                    "Carrefour",
880                    vec![
881                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
882                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
883                    ],
884                )),
885            ],
886        };
887
888        assert_eq!(
889            validate_journal(journal),
890            Err(ValidationError::PayeeNotDeclared {
891                payee: "Carrefour".to_string(),
892                date: date(2024, 1, 5),
893            })
894        );
895    }
896
897    #[test]
898    fn accepts_transaction_with_no_payee_without_checking_declarations() {
899        let journal = Journal {
900            entries: vec![
901                open(date(2024, 1, 1), "Actifs:Compte"),
902                open(date(2024, 1, 1), "Depenses:Divers"),
903                commodity(date(2024, 1, 1), "EUR"),
904                Entry::Transaction(transaction(
905                    date(2024, 1, 5),
906                    vec![
907                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
908                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
909                    ],
910                )),
911            ],
912        };
913
914        assert!(validate_journal(journal).is_ok());
915    }
916
917    #[test]
918    fn accepts_balance_assertion_matching_running_balance() {
919        let journal = Journal {
920            entries: vec![
921                open(date(2024, 1, 1), "Actifs:Compte"),
922                open(date(2024, 1, 1), "Depenses:Divers"),
923                commodity(date(2024, 1, 1), "EUR"),
924                Entry::Transaction(transaction(
925                    date(2024, 1, 5),
926                    vec![
927                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
928                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
929                    ],
930                )),
931                balance(date(2024, 1, 10), "Actifs:Compte", ("-100.00", "EUR")),
932            ],
933        };
934
935        assert!(validate_journal(journal).is_ok());
936    }
937
938    #[test]
939    fn rejects_balance_assertion_not_matching_running_balance() {
940        let journal = Journal {
941            entries: vec![
942                open(date(2024, 1, 1), "Actifs:Compte"),
943                open(date(2024, 1, 1), "Depenses:Divers"),
944                commodity(date(2024, 1, 1), "EUR"),
945                Entry::Transaction(transaction(
946                    date(2024, 1, 5),
947                    vec![
948                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
949                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
950                    ],
951                )),
952                balance(date(2024, 1, 10), "Actifs:Compte", ("-50.00", "EUR")),
953            ],
954        };
955
956        assert_eq!(
957            validate_journal(journal),
958            Err(ValidationError::BalanceAssertionFailed {
959                account: AccountName::new("Actifs:Compte").unwrap(),
960                commodity: CommodityName::new("EUR").unwrap(),
961                date: date(2024, 1, 10),
962                expected: "-50.00".parse().unwrap(),
963                actual: "-100.00".parse().unwrap(),
964                tolerance: Decimal::ZERO,
965            })
966        );
967    }
968
969    #[test]
970    fn accepts_balance_assertion_within_tolerance() {
971        let journal = Journal {
972            entries: vec![
973                open(date(2024, 1, 1), "Actifs:Compte"),
974                open(date(2024, 1, 1), "Depenses:Divers"),
975                commodity(date(2024, 1, 1), "EUR"),
976                Entry::Transaction(transaction(
977                    date(2024, 1, 5),
978                    vec![
979                        posting("Actifs:Compte", Some(("-100.01", "EUR"))),
980                        posting("Depenses:Divers", Some(("100.01", "EUR"))),
981                    ],
982                )),
983                balance_with_tolerance(
984                    date(2024, 1, 10),
985                    "Actifs:Compte",
986                    ("-100.00", "EUR"),
987                    Some("0.01"),
988                ),
989            ],
990        };
991
992        assert!(validate_journal(journal).is_ok());
993    }
994
995    #[test]
996    fn rejects_balance_assertion_outside_tolerance() {
997        let journal = Journal {
998            entries: vec![
999                open(date(2024, 1, 1), "Actifs:Compte"),
1000                open(date(2024, 1, 1), "Depenses:Divers"),
1001                commodity(date(2024, 1, 1), "EUR"),
1002                Entry::Transaction(transaction(
1003                    date(2024, 1, 5),
1004                    vec![
1005                        posting("Actifs:Compte", Some(("-100.02", "EUR"))),
1006                        posting("Depenses:Divers", Some(("100.02", "EUR"))),
1007                    ],
1008                )),
1009                balance_with_tolerance(
1010                    date(2024, 1, 10),
1011                    "Actifs:Compte",
1012                    ("-100.00", "EUR"),
1013                    Some("0.01"),
1014                ),
1015            ],
1016        };
1017
1018        assert_eq!(
1019            validate_journal(journal),
1020            Err(ValidationError::BalanceAssertionFailed {
1021                account: AccountName::new("Actifs:Compte").unwrap(),
1022                commodity: CommodityName::new("EUR").unwrap(),
1023                date: date(2024, 1, 10),
1024                expected: "-100.00".parse().unwrap(),
1025                actual: "-100.02".parse().unwrap(),
1026                tolerance: "0.01".parse().unwrap(),
1027            })
1028        );
1029    }
1030
1031    #[test]
1032    fn balance_assertion_only_sees_entries_up_to_its_stable_sort_position() {
1033        // Two entries share the same date: the transaction and the
1034        // balance assertion. Since both are on 2024-01-05, the stable
1035        // sort keeps their original relative order, so the assertion
1036        // (declared after the transaction in the input) must see its
1037        // effect.
1038        let journal = Journal {
1039            entries: vec![
1040                open(date(2024, 1, 1), "Actifs:Compte"),
1041                open(date(2024, 1, 1), "Depenses:Divers"),
1042                commodity(date(2024, 1, 1), "EUR"),
1043                Entry::Transaction(transaction(
1044                    date(2024, 1, 5),
1045                    vec![
1046                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
1047                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
1048                    ],
1049                )),
1050                balance(date(2024, 1, 5), "Actifs:Compte", ("-100.00", "EUR")),
1051            ],
1052        };
1053
1054        assert!(validate_journal(journal).is_ok());
1055    }
1056
1057    #[test]
1058    fn accepts_posting_using_a_commodity_allowed_by_open() {
1059        let journal = Journal {
1060            entries: vec![
1061                open_restricted(date(2024, 1, 1), "Actifs:Compte", &["EUR", "USD"]),
1062                open(date(2024, 1, 1), "Depenses:Divers"),
1063                commodity(date(2024, 1, 1), "EUR"),
1064                Entry::Transaction(transaction(
1065                    date(2024, 1, 5),
1066                    vec![
1067                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
1068                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
1069                    ],
1070                )),
1071            ],
1072        };
1073
1074        assert!(validate_journal(journal).is_ok());
1075    }
1076
1077    #[test]
1078    fn rejects_posting_using_a_commodity_not_allowed_by_open() {
1079        let journal = Journal {
1080            entries: vec![
1081                open_restricted(date(2024, 1, 1), "Actifs:Compte", &["USD"]),
1082                open(date(2024, 1, 1), "Depenses:Divers"),
1083                commodity(date(2024, 1, 1), "EUR"),
1084                Entry::Transaction(transaction(
1085                    date(2024, 1, 5),
1086                    vec![
1087                        posting("Actifs:Compte", Some(("-100.00", "EUR"))),
1088                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
1089                    ],
1090                )),
1091            ],
1092        };
1093
1094        assert_eq!(
1095            validate_journal(journal),
1096            Err(ValidationError::CommodityNotAllowed {
1097                account: AccountName::new("Actifs:Compte").unwrap(),
1098                commodity: CommodityName::new("EUR").unwrap(),
1099                date: date(2024, 1, 5),
1100            })
1101        );
1102    }
1103
1104    #[test]
1105    fn rejects_auto_balanced_posting_using_a_commodity_not_allowed_by_open() {
1106        // `Actifs:Compte`'s missing amount is auto-filled with EUR
1107        // (inferred from `Depenses:Divers`), but `Actifs:Compte` is
1108        // restricted to USD only: the auto-balanced posting must still be
1109        // rejected.
1110        let journal = Journal {
1111            entries: vec![
1112                open_restricted(date(2024, 1, 1), "Actifs:Compte", &["USD"]),
1113                open(date(2024, 1, 1), "Depenses:Divers"),
1114                commodity(date(2024, 1, 1), "EUR"),
1115                Entry::Transaction(transaction(
1116                    date(2024, 1, 5),
1117                    vec![
1118                        posting("Actifs:Compte", None),
1119                        posting("Depenses:Divers", Some(("100.00", "EUR"))),
1120                    ],
1121                )),
1122            ],
1123        };
1124
1125        assert_eq!(
1126            validate_journal(journal),
1127            Err(ValidationError::CommodityNotAllowed {
1128                account: AccountName::new("Actifs:Compte").unwrap(),
1129                commodity: CommodityName::new("EUR").unwrap(),
1130                date: date(2024, 1, 5),
1131            })
1132        );
1133    }
1134}