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