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