1#![forbid(unsafe_code)]
46#![warn(missing_docs)]
47
48mod error;
49mod validators;
50
51pub use error::{ErrorCode, Severity, ValidationError};
52
53use validators::{
54 validate_balance, validate_close, validate_document, validate_note, validate_open,
55 validate_pad, validate_transaction,
56};
57
58use chrono::{Local, NaiveDate};
59use rayon::prelude::*;
60
61const PARALLEL_SORT_THRESHOLD: usize = 5000;
64use rust_decimal::Decimal;
65use rustc_hash::{FxHashMap, FxHashSet};
66use rustledger_core::{BookingMethod, Directive, InternedStr, Inventory};
67use rustledger_parser::Spanned;
68
69#[derive(Debug, Clone)]
71struct AccountState {
72 opened: NaiveDate,
74 closed: Option<NaiveDate>,
76 currencies: FxHashSet<InternedStr>,
78 #[allow(dead_code)]
80 booking: BookingMethod,
81}
82
83#[derive(Debug, Clone)]
85pub struct ValidationOptions {
86 pub require_commodities: bool,
88 pub check_documents: bool,
90 pub warn_future_dates: bool,
92 pub document_base: Option<std::path::PathBuf>,
94 pub account_types: Vec<String>,
97 pub infer_tolerance_from_cost: bool,
100 pub tolerance_multiplier: Decimal,
103 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
106}
107
108impl Default for ValidationOptions {
109 fn default() -> Self {
110 Self {
111 require_commodities: false,
112 check_documents: true, warn_future_dates: false,
114 document_base: None,
115 account_types: vec![
116 "Assets".to_string(),
117 "Liabilities".to_string(),
118 "Equity".to_string(),
119 "Income".to_string(),
120 "Expenses".to_string(),
121 ],
122 infer_tolerance_from_cost: false,
124 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132struct PendingPad {
133 source_account: InternedStr,
135 date: NaiveDate,
137 used: bool,
139}
140
141#[derive(Debug, Default)]
143pub struct LedgerState {
144 accounts: FxHashMap<InternedStr, AccountState>,
146 inventories: FxHashMap<InternedStr, Inventory>,
148 commodities: FxHashSet<InternedStr>,
150 pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
152 options: ValidationOptions,
154 last_date: Option<NaiveDate>,
156 tolerances: FxHashMap<InternedStr, Decimal>,
159}
160
161impl LedgerState {
162 #[must_use]
164 pub fn new() -> Self {
165 Self::default()
166 }
167
168 #[must_use]
170 pub fn with_options(options: ValidationOptions) -> Self {
171 Self {
172 options,
173 ..Default::default()
174 }
175 }
176
177 pub const fn set_require_commodities(&mut self, require: bool) {
179 self.options.require_commodities = require;
180 }
181
182 pub const fn set_check_documents(&mut self, check: bool) {
184 self.options.check_documents = check;
185 }
186
187 pub const fn set_warn_future_dates(&mut self, warn: bool) {
189 self.options.warn_future_dates = warn;
190 }
191
192 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
194 self.options.document_base = Some(base.into());
195 }
196
197 #[must_use]
199 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
200 self.inventories.get(account)
201 }
202
203 pub fn accounts(&self) -> impl Iterator<Item = &str> {
205 self.accounts.keys().map(InternedStr::as_str)
206 }
207}
208
209pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
213 validate_with_options(directives, ValidationOptions::default())
214}
215
216pub fn validate_with_options(
220 directives: &[Directive],
221 options: ValidationOptions,
222) -> Vec<ValidationError> {
223 let mut state = LedgerState::with_options(options);
224 let mut errors = Vec::new();
225
226 let today = Local::now().date_naive();
227
228 let mut sorted: Vec<&Directive> = Vec::with_capacity(directives.len());
232 sorted.extend(directives.iter());
233 let sort_fn = |a: &&Directive, b: &&Directive| {
234 a.date()
235 .cmp(&b.date())
236 .then_with(|| a.priority().cmp(&b.priority()))
237 };
238 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
239 sorted.par_sort_by(sort_fn);
240 } else {
241 sorted.sort_by(sort_fn);
242 }
243
244 for directive in sorted {
245 let date = directive.date();
246
247 if let Some(last) = state.last_date
249 && date < last
250 {
251 errors.push(ValidationError::new(
252 ErrorCode::DateOutOfOrder,
253 format!("Directive date {date} is before previous directive {last}"),
254 date,
255 ));
256 }
257 state.last_date = Some(date);
258
259 if state.options.warn_future_dates && date > today {
261 errors.push(ValidationError::new(
262 ErrorCode::FutureDate,
263 format!("Entry dated in the future: {date}"),
264 date,
265 ));
266 }
267
268 match directive {
269 Directive::Open(open) => {
270 validate_open(&mut state, open, &mut errors);
271 }
272 Directive::Close(close) => {
273 validate_close(&mut state, close, &mut errors);
274 }
275 Directive::Transaction(txn) => {
276 validate_transaction(&mut state, txn, &mut errors);
277 }
278 Directive::Balance(bal) => {
279 validate_balance(&mut state, bal, &mut errors);
280 }
281 Directive::Commodity(comm) => {
282 state.commodities.insert(comm.currency.clone());
283 }
284 Directive::Pad(pad) => {
285 validate_pad(&mut state, pad, &mut errors);
286 }
287 Directive::Document(doc) => {
288 validate_document(&state, doc, &mut errors);
289 }
290 Directive::Note(note) => {
291 validate_note(&state, note, &mut errors);
292 }
293 _ => {}
294 }
295 }
296
297 for (target_account, pads) in &state.pending_pads {
299 for pad in pads {
300 if !pad.used {
301 errors.push(
302 ValidationError::new(
303 ErrorCode::PadWithoutBalance,
304 "Unused Pad entry".to_string(),
305 pad.date,
306 )
307 .with_context(format!(
308 " {} pad {} {}",
309 pad.date, target_account, pad.source_account
310 )),
311 );
312 }
313 }
314 }
315
316 errors
317}
318
319pub fn validate_spanned_with_options(
328 directives: &[Spanned<Directive>],
329 options: ValidationOptions,
330) -> Vec<ValidationError> {
331 let mut state = LedgerState::with_options(options);
332 let mut errors = Vec::new();
333
334 let today = Local::now().date_naive();
335
336 let mut sorted: Vec<&Spanned<Directive>> = directives.iter().collect();
339 let sort_fn = |a: &&Spanned<Directive>, b: &&Spanned<Directive>| {
340 a.value
341 .date()
342 .cmp(&b.value.date())
343 .then_with(|| a.value.priority().cmp(&b.value.priority()))
344 };
345 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
346 sorted.par_sort_by(sort_fn);
347 } else {
348 sorted.sort_by(sort_fn);
349 }
350
351 for spanned in sorted {
352 let directive = &spanned.value;
353 let date = directive.date();
354
355 if let Some(last) = state.last_date
357 && date < last
358 {
359 errors.push(ValidationError::with_location(
360 ErrorCode::DateOutOfOrder,
361 format!("Directive date {date} is before previous directive {last}"),
362 date,
363 spanned,
364 ));
365 }
366 state.last_date = Some(date);
367
368 if state.options.warn_future_dates && date > today {
370 errors.push(ValidationError::with_location(
371 ErrorCode::FutureDate,
372 format!("Entry dated in the future: {date}"),
373 date,
374 spanned,
375 ));
376 }
377
378 let error_count_before = errors.len();
380
381 match directive {
382 Directive::Open(open) => {
383 validate_open(&mut state, open, &mut errors);
384 }
385 Directive::Close(close) => {
386 validate_close(&mut state, close, &mut errors);
387 }
388 Directive::Transaction(txn) => {
389 validate_transaction(&mut state, txn, &mut errors);
390 }
391 Directive::Balance(bal) => {
392 validate_balance(&mut state, bal, &mut errors);
393 }
394 Directive::Commodity(comm) => {
395 state.commodities.insert(comm.currency.clone());
396 }
397 Directive::Pad(pad) => {
398 validate_pad(&mut state, pad, &mut errors);
399 }
400 Directive::Document(doc) => {
401 validate_document(&state, doc, &mut errors);
402 }
403 Directive::Note(note) => {
404 validate_note(&state, note, &mut errors);
405 }
406 _ => {}
407 }
408
409 for error in errors.iter_mut().skip(error_count_before) {
411 if error.span.is_none() {
412 error.span = Some(spanned.span);
413 error.file_id = Some(spanned.file_id);
414 }
415 }
416 }
417
418 for (target_account, pads) in &state.pending_pads {
421 for pad in pads {
422 if !pad.used {
423 errors.push(
424 ValidationError::new(
425 ErrorCode::PadWithoutBalance,
426 "Unused Pad entry".to_string(),
427 pad.date,
428 )
429 .with_context(format!(
430 " {} pad {} {}",
431 pad.date, target_account, pad.source_account
432 )),
433 );
434 }
435 }
436 }
437
438 errors
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use rust_decimal_macros::dec;
445 use rustledger_core::{
446 Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
447 };
448
449 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
450 NaiveDate::from_ymd_opt(year, month, day).unwrap()
451 }
452
453 #[test]
454 fn test_validate_account_lifecycle() {
455 let directives = vec![
456 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
457 Directive::Transaction(
458 Transaction::new(date(2024, 1, 15), "Test")
459 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
460 .with_posting(Posting::new(
461 "Income:Salary",
462 Amount::new(dec!(-100), "USD"),
463 )),
464 ),
465 ];
466
467 let errors = validate(&directives);
468
469 assert!(errors
471 .iter()
472 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
473 }
474
475 #[test]
476 fn test_validate_account_used_before_open() {
477 let directives = vec![
478 Directive::Transaction(
479 Transaction::new(date(2024, 1, 1), "Test")
480 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
481 .with_posting(Posting::new(
482 "Income:Salary",
483 Amount::new(dec!(-100), "USD"),
484 )),
485 ),
486 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
487 ];
488
489 let errors = validate(&directives);
490
491 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
492 }
493
494 #[test]
495 fn test_validate_account_used_after_close() {
496 let directives = vec![
497 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
498 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
499 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
500 Directive::Transaction(
501 Transaction::new(date(2024, 7, 1), "Test")
502 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
503 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
504 ),
505 ];
506
507 let errors = validate(&directives);
508
509 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
510 }
511
512 #[test]
513 fn test_validate_balance_assertion() {
514 let directives = vec![
515 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
516 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
517 Directive::Transaction(
518 Transaction::new(date(2024, 1, 15), "Deposit")
519 .with_posting(Posting::new(
520 "Assets:Bank",
521 Amount::new(dec!(1000.00), "USD"),
522 ))
523 .with_posting(Posting::new(
524 "Income:Salary",
525 Amount::new(dec!(-1000.00), "USD"),
526 )),
527 ),
528 Directive::Balance(Balance::new(
529 date(2024, 1, 16),
530 "Assets:Bank",
531 Amount::new(dec!(1000.00), "USD"),
532 )),
533 ];
534
535 let errors = validate(&directives);
536 assert!(errors.is_empty(), "{errors:?}");
537 }
538
539 #[test]
540 fn test_validate_balance_assertion_failed() {
541 let directives = vec![
542 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
543 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
544 Directive::Transaction(
545 Transaction::new(date(2024, 1, 15), "Deposit")
546 .with_posting(Posting::new(
547 "Assets:Bank",
548 Amount::new(dec!(1000.00), "USD"),
549 ))
550 .with_posting(Posting::new(
551 "Income:Salary",
552 Amount::new(dec!(-1000.00), "USD"),
553 )),
554 ),
555 Directive::Balance(Balance::new(
556 date(2024, 1, 16),
557 "Assets:Bank",
558 Amount::new(dec!(500.00), "USD"), )),
560 ];
561
562 let errors = validate(&directives);
563 assert!(
564 errors
565 .iter()
566 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
567 );
568 }
569
570 #[test]
576 fn test_validate_balance_assertion_within_tolerance() {
577 let directives = vec![
582 Directive::Open(
583 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
584 ),
585 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
586 Directive::Transaction(
587 Transaction::new(date(2024, 1, 15), "Deposit")
588 .with_posting(Posting::new(
589 "Assets:Bank",
590 Amount::new(dec!(70.538), "ABC"), ))
592 .with_posting(Posting::new(
593 "Expenses:Misc",
594 Amount::new(dec!(-70.538), "ABC"),
595 )),
596 ),
597 Directive::Balance(Balance::new(
598 date(2024, 1, 16),
599 "Assets:Bank",
600 Amount::new(dec!(70.53), "ABC"), )),
602 ];
603
604 let errors = validate(&directives);
605 assert!(
606 errors.is_empty(),
607 "Balance within tolerance should pass: {errors:?}"
608 );
609 }
610
611 #[test]
613 fn test_validate_balance_assertion_exceeds_tolerance() {
614 let directives = vec![
619 Directive::Open(
620 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
621 ),
622 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
623 Directive::Transaction(
624 Transaction::new(date(2024, 1, 15), "Deposit")
625 .with_posting(Posting::new(
626 "Assets:Bank",
627 Amount::new(dec!(70.542), "ABC"),
628 ))
629 .with_posting(Posting::new(
630 "Expenses:Misc",
631 Amount::new(dec!(-70.542), "ABC"),
632 )),
633 ),
634 Directive::Balance(Balance::new(
635 date(2024, 1, 16),
636 "Assets:Bank",
637 Amount::new(dec!(70.53), "ABC"), )),
639 ];
640
641 let errors = validate(&directives);
642 assert!(
643 errors
644 .iter()
645 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
646 "Balance exceeding tolerance should fail"
647 );
648 }
649
650 #[test]
651 fn test_validate_unbalanced_transaction() {
652 let directives = vec![
653 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
654 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
655 Directive::Transaction(
656 Transaction::new(date(2024, 1, 15), "Unbalanced")
657 .with_posting(Posting::new(
658 "Assets:Bank",
659 Amount::new(dec!(-50.00), "USD"),
660 ))
661 .with_posting(Posting::new(
662 "Expenses:Food",
663 Amount::new(dec!(40.00), "USD"),
664 )), ),
666 ];
667
668 let errors = validate(&directives);
669 assert!(
670 errors
671 .iter()
672 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
673 );
674 }
675
676 #[test]
677 fn test_validate_currency_not_allowed() {
678 let directives = vec![
679 Directive::Open(
680 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
681 ),
682 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
683 Directive::Transaction(
684 Transaction::new(date(2024, 1, 15), "Test")
685 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
687 "Income:Salary",
688 Amount::new(dec!(-100.00), "EUR"),
689 )),
690 ),
691 ];
692
693 let errors = validate(&directives);
694 assert!(
695 errors
696 .iter()
697 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
698 );
699 }
700
701 #[test]
702 fn test_validate_future_date_warning() {
703 let future_date = Local::now().date_naive() + chrono::Duration::days(30);
705
706 let directives = vec![Directive::Open(Open {
707 date: future_date,
708 account: "Assets:Bank".into(),
709 currencies: vec![],
710 booking: None,
711 meta: Default::default(),
712 })];
713
714 let errors = validate(&directives);
716 assert!(
717 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
718 "Should not warn about future dates by default"
719 );
720
721 let options = ValidationOptions {
723 warn_future_dates: true,
724 ..Default::default()
725 };
726 let errors = validate_with_options(&directives, options);
727 assert!(
728 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
729 "Should warn about future dates when enabled"
730 );
731 }
732
733 #[test]
734 fn test_validate_document_not_found() {
735 let directives = vec![
736 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
737 Directive::Document(Document {
738 date: date(2024, 1, 15),
739 account: "Assets:Bank".into(),
740 path: "/nonexistent/path/to/document.pdf".to_string(),
741 tags: vec![],
742 links: vec![],
743 meta: Default::default(),
744 }),
745 ];
746
747 let errors = validate(&directives);
749 assert!(
750 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
751 "Should check documents by default"
752 );
753
754 let options = ValidationOptions {
756 check_documents: false,
757 ..Default::default()
758 };
759 let errors = validate_with_options(&directives, options);
760 assert!(
761 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
762 "Should not report missing document when disabled"
763 );
764 }
765
766 #[test]
767 fn test_validate_document_account_not_open() {
768 let directives = vec![Directive::Document(Document {
769 date: date(2024, 1, 15),
770 account: "Assets:Unknown".into(),
771 path: "receipt.pdf".to_string(),
772 tags: vec![],
773 links: vec![],
774 meta: Default::default(),
775 })];
776
777 let errors = validate(&directives);
778 assert!(
779 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
780 "Should error for document on unopened account"
781 );
782 }
783
784 #[test]
785 fn test_error_code_is_warning() {
786 assert!(!ErrorCode::AccountNotOpen.is_warning());
787 assert!(!ErrorCode::DocumentNotFound.is_warning());
788 assert!(ErrorCode::FutureDate.is_warning());
789 }
790
791 #[test]
792 fn test_validate_pad_basic() {
793 let directives = vec![
794 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
795 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
796 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
797 Directive::Balance(Balance::new(
798 date(2024, 1, 2),
799 "Assets:Bank",
800 Amount::new(dec!(1000.00), "USD"),
801 )),
802 ];
803
804 let errors = validate(&directives);
805 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
807 }
808
809 #[test]
810 fn test_validate_pad_with_existing_balance() {
811 let directives = vec![
812 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
813 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
814 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
815 Directive::Transaction(
817 Transaction::new(date(2024, 1, 5), "Initial deposit")
818 .with_posting(Posting::new(
819 "Assets:Bank",
820 Amount::new(dec!(500.00), "USD"),
821 ))
822 .with_posting(Posting::new(
823 "Income:Salary",
824 Amount::new(dec!(-500.00), "USD"),
825 )),
826 ),
827 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
829 Directive::Balance(Balance::new(
830 date(2024, 1, 15),
831 "Assets:Bank",
832 Amount::new(dec!(1000.00), "USD"), )),
834 ];
835
836 let errors = validate(&directives);
837 assert!(
839 errors.is_empty(),
840 "Pad should add missing amount: {errors:?}"
841 );
842 }
843
844 #[test]
845 fn test_validate_pad_account_not_open() {
846 let directives = vec![
847 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
848 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
850 ];
851
852 let errors = validate(&directives);
853 assert!(
854 errors
855 .iter()
856 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
857 "Should error for pad on unopened account"
858 );
859 }
860
861 #[test]
862 fn test_validate_pad_source_not_open() {
863 let directives = vec![
864 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
865 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
867 ];
868
869 let errors = validate(&directives);
870 assert!(
871 errors.iter().any(
872 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
873 ),
874 "Should error for pad with unopened source account"
875 );
876 }
877
878 #[test]
879 fn test_validate_pad_negative_adjustment() {
880 let directives = vec![
882 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
883 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
884 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
885 Directive::Transaction(
887 Transaction::new(date(2024, 1, 5), "Big deposit")
888 .with_posting(Posting::new(
889 "Assets:Bank",
890 Amount::new(dec!(2000.00), "USD"),
891 ))
892 .with_posting(Posting::new(
893 "Income:Salary",
894 Amount::new(dec!(-2000.00), "USD"),
895 )),
896 ),
897 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
899 Directive::Balance(Balance::new(
900 date(2024, 1, 15),
901 "Assets:Bank",
902 Amount::new(dec!(1000.00), "USD"), )),
904 ];
905
906 let errors = validate(&directives);
907 assert!(
908 errors.is_empty(),
909 "Pad should handle negative adjustment: {errors:?}"
910 );
911 }
912
913 #[test]
914 fn test_validate_insufficient_units() {
915 use rustledger_core::CostSpec;
916
917 let cost_spec = CostSpec::empty()
918 .with_number_per(dec!(150))
919 .with_currency("USD");
920
921 let directives = vec![
922 Directive::Open(
923 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
924 ),
925 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
926 Directive::Transaction(
928 Transaction::new(date(2024, 1, 15), "Buy")
929 .with_posting(
930 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
931 .with_cost(cost_spec.clone()),
932 )
933 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
934 ),
935 Directive::Transaction(
937 Transaction::new(date(2024, 6, 1), "Sell too many")
938 .with_posting(
939 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
940 .with_cost(cost_spec),
941 )
942 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
943 ),
944 ];
945
946 let errors = validate(&directives);
947 assert!(
948 errors
949 .iter()
950 .any(|e| e.code == ErrorCode::InsufficientUnits),
951 "Should error for insufficient units: {errors:?}"
952 );
953 }
954
955 #[test]
956 fn test_validate_no_matching_lot() {
957 use rustledger_core::CostSpec;
958
959 let directives = vec![
960 Directive::Open(
961 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
962 ),
963 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
964 Directive::Transaction(
966 Transaction::new(date(2024, 1, 15), "Buy")
967 .with_posting(
968 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
969 CostSpec::empty()
970 .with_number_per(dec!(150))
971 .with_currency("USD"),
972 ),
973 )
974 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
975 ),
976 Directive::Transaction(
978 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
979 .with_posting(
980 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
981 CostSpec::empty()
982 .with_number_per(dec!(160))
983 .with_currency("USD"),
984 ),
985 )
986 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
987 ),
988 ];
989
990 let errors = validate(&directives);
991 assert!(
992 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
993 "Should error for no matching lot: {errors:?}"
994 );
995 }
996
997 #[test]
998 fn test_validate_multiple_lot_match_uses_fifo() {
999 use rustledger_core::CostSpec;
1002
1003 let cost_spec = CostSpec::empty()
1004 .with_number_per(dec!(150))
1005 .with_currency("USD");
1006
1007 let directives = vec![
1008 Directive::Open(
1009 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1010 ),
1011 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1012 Directive::Transaction(
1014 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1015 .with_posting(
1016 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1017 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1018 )
1019 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1020 ),
1021 Directive::Transaction(
1023 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1024 .with_posting(
1025 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1026 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1027 )
1028 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1029 ),
1030 Directive::Transaction(
1032 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1033 .with_posting(
1034 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1035 .with_cost(cost_spec),
1036 )
1037 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1038 ),
1039 ];
1040
1041 let errors = validate(&directives);
1042 let booking_errors: Vec<_> = errors
1044 .iter()
1045 .filter(|e| {
1046 matches!(
1047 e.code,
1048 ErrorCode::InsufficientUnits
1049 | ErrorCode::NoMatchingLot
1050 | ErrorCode::AmbiguousLotMatch
1051 )
1052 })
1053 .collect();
1054 assert!(
1055 booking_errors.is_empty(),
1056 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_validate_successful_booking() {
1062 use rustledger_core::CostSpec;
1063
1064 let cost_spec = CostSpec::empty()
1065 .with_number_per(dec!(150))
1066 .with_currency("USD");
1067
1068 let directives = vec![
1069 Directive::Open(
1070 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1071 ),
1072 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1073 Directive::Transaction(
1075 Transaction::new(date(2024, 1, 15), "Buy")
1076 .with_posting(
1077 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1078 .with_cost(cost_spec.clone()),
1079 )
1080 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1081 ),
1082 Directive::Transaction(
1084 Transaction::new(date(2024, 6, 1), "Sell")
1085 .with_posting(
1086 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1087 .with_cost(cost_spec),
1088 )
1089 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1090 ),
1091 ];
1092
1093 let errors = validate(&directives);
1094 let booking_errors: Vec<_> = errors
1096 .iter()
1097 .filter(|e| {
1098 matches!(
1099 e.code,
1100 ErrorCode::InsufficientUnits
1101 | ErrorCode::NoMatchingLot
1102 | ErrorCode::AmbiguousLotMatch
1103 )
1104 })
1105 .collect();
1106 assert!(
1107 booking_errors.is_empty(),
1108 "Should have no booking errors: {booking_errors:?}"
1109 );
1110 }
1111
1112 #[test]
1113 fn test_validate_account_already_open() {
1114 let directives = vec![
1115 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1116 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1118
1119 let errors = validate(&directives);
1120 assert!(
1121 errors
1122 .iter()
1123 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1124 "Should error for duplicate open: {errors:?}"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_validate_account_close_not_empty() {
1130 let directives = vec![
1131 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1132 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1133 Directive::Transaction(
1134 Transaction::new(date(2024, 1, 15), "Deposit")
1135 .with_posting(Posting::new(
1136 "Assets:Bank",
1137 Amount::new(dec!(100.00), "USD"),
1138 ))
1139 .with_posting(Posting::new(
1140 "Income:Salary",
1141 Amount::new(dec!(-100.00), "USD"),
1142 )),
1143 ),
1144 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1146
1147 let errors = validate(&directives);
1148 assert!(
1149 errors
1150 .iter()
1151 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1152 "Should warn for closing account with balance: {errors:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_validate_no_postings_allowed() {
1158 let directives = vec![
1161 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1162 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1163 ];
1164
1165 let errors = validate(&directives);
1166 assert!(
1167 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1168 "Should NOT error for transaction with no postings: {errors:?}"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_validate_single_posting() {
1174 let directives = vec![
1175 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1176 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1177 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1178 )),
1179 ];
1180
1181 let errors = validate(&directives);
1182 assert!(
1183 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1184 "Should warn for transaction with single posting: {errors:?}"
1185 );
1186 assert!(ErrorCode::SinglePosting.is_warning());
1188 }
1189
1190 #[test]
1191 fn test_validate_pad_without_balance() {
1192 let directives = vec![
1193 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1194 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1195 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1196 ];
1198
1199 let errors = validate(&directives);
1200 assert!(
1201 errors
1202 .iter()
1203 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1204 "Should error for pad without subsequent balance: {errors:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_validate_multiple_pads_for_balance() {
1210 let directives = vec![
1211 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1212 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1213 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1214 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1216 date(2024, 1, 3),
1217 "Assets:Bank",
1218 Amount::new(dec!(1000.00), "USD"),
1219 )),
1220 ];
1221
1222 let errors = validate(&directives);
1223 assert!(
1224 errors
1225 .iter()
1226 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1227 "Should error for multiple pads before balance: {errors:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_error_severity() {
1233 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1235 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1236 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1237
1238 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1240 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1241 assert_eq!(
1242 ErrorCode::AccountCloseNotEmpty.severity(),
1243 Severity::Warning
1244 );
1245
1246 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1248 }
1249
1250 #[test]
1251 fn test_validate_invalid_account_name() {
1252 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1254
1255 let errors = validate(&directives);
1256 assert!(
1257 errors
1258 .iter()
1259 .any(|e| e.code == ErrorCode::InvalidAccountName),
1260 "Should error for invalid account root: {errors:?}"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_validate_account_lowercase_component() {
1266 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1268
1269 let errors = validate(&directives);
1270 assert!(
1271 errors
1272 .iter()
1273 .any(|e| e.code == ErrorCode::InvalidAccountName),
1274 "Should error for lowercase component: {errors:?}"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_validate_valid_account_names() {
1280 let valid_names = [
1282 "Assets:Bank",
1283 "Assets:Bank:Checking",
1284 "Liabilities:CreditCard",
1285 "Equity:Opening-Balances",
1286 "Income:Salary2024",
1287 "Expenses:Food:Restaurant",
1288 "Assets:401k", "Assets:CORP✨", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Test💰Account", "Assets:€uro", ];
1296
1297 for name in valid_names {
1298 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1299
1300 let errors = validate(&directives);
1301 let name_errors: Vec<_> = errors
1302 .iter()
1303 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1304 .collect();
1305 assert!(
1306 name_errors.is_empty(),
1307 "Should accept valid account name '{name}': {name_errors:?}"
1308 );
1309 }
1310 }
1311}