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