1use crate::errors::ValidationError;
4use crate::models::{AccountName, Amount, CommodityName, Entry, Journal, Transaction};
5use rust_decimal::Decimal;
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8#[derive(Debug, Clone, PartialEq)]
11pub struct ValidationReport {
12 pub journal: Journal,
14 pub issues: Vec<ValidationIssue>,
16}
17
18impl ValidationReport {
19 pub fn errors(&self) -> impl Iterator<Item = &ValidationError> {
21 self.issues.iter().map(|issue| &issue.error)
22 }
23}
24
25#[derive(Debug, Clone, PartialEq)]
32pub struct ValidationIssue {
33 pub input_index: usize,
36 pub error: ValidationError,
38}
39
40pub 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
53pub 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 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 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
231fn 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 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}
300fn 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 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 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 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 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 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}