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