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]
547 fn test_validate_unbalanced_transaction() {
548 let directives = vec![
549 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
550 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
551 Directive::Transaction(
552 Transaction::new(date(2024, 1, 15), "Unbalanced")
553 .with_posting(Posting::new(
554 "Assets:Bank",
555 Amount::new(dec!(-50.00), "USD"),
556 ))
557 .with_posting(Posting::new(
558 "Expenses:Food",
559 Amount::new(dec!(40.00), "USD"),
560 )), ),
562 ];
563
564 let errors = validate(&directives);
565 assert!(
566 errors
567 .iter()
568 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
569 );
570 }
571
572 #[test]
573 fn test_validate_currency_not_allowed() {
574 let directives = vec![
575 Directive::Open(
576 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
577 ),
578 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
579 Directive::Transaction(
580 Transaction::new(date(2024, 1, 15), "Test")
581 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
583 "Income:Salary",
584 Amount::new(dec!(-100.00), "EUR"),
585 )),
586 ),
587 ];
588
589 let errors = validate(&directives);
590 assert!(
591 errors
592 .iter()
593 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
594 );
595 }
596
597 #[test]
598 fn test_validate_future_date_warning() {
599 let future_date = Local::now().date_naive() + chrono::Duration::days(30);
601
602 let directives = vec![Directive::Open(Open {
603 date: future_date,
604 account: "Assets:Bank".into(),
605 currencies: vec![],
606 booking: None,
607 meta: Default::default(),
608 })];
609
610 let errors = validate(&directives);
612 assert!(
613 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
614 "Should not warn about future dates by default"
615 );
616
617 let options = ValidationOptions {
619 warn_future_dates: true,
620 ..Default::default()
621 };
622 let errors = validate_with_options(&directives, options);
623 assert!(
624 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
625 "Should warn about future dates when enabled"
626 );
627 }
628
629 #[test]
630 fn test_validate_document_not_found() {
631 let directives = vec![
632 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
633 Directive::Document(Document {
634 date: date(2024, 1, 15),
635 account: "Assets:Bank".into(),
636 path: "/nonexistent/path/to/document.pdf".to_string(),
637 tags: vec![],
638 links: vec![],
639 meta: Default::default(),
640 }),
641 ];
642
643 let errors = validate(&directives);
645 assert!(
646 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
647 "Should check documents by default"
648 );
649
650 let options = ValidationOptions {
652 check_documents: false,
653 ..Default::default()
654 };
655 let errors = validate_with_options(&directives, options);
656 assert!(
657 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
658 "Should not report missing document when disabled"
659 );
660 }
661
662 #[test]
663 fn test_validate_document_account_not_open() {
664 let directives = vec![Directive::Document(Document {
665 date: date(2024, 1, 15),
666 account: "Assets:Unknown".into(),
667 path: "receipt.pdf".to_string(),
668 tags: vec![],
669 links: vec![],
670 meta: Default::default(),
671 })];
672
673 let errors = validate(&directives);
674 assert!(
675 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
676 "Should error for document on unopened account"
677 );
678 }
679
680 #[test]
681 fn test_error_code_is_warning() {
682 assert!(!ErrorCode::AccountNotOpen.is_warning());
683 assert!(!ErrorCode::DocumentNotFound.is_warning());
684 assert!(ErrorCode::FutureDate.is_warning());
685 }
686
687 #[test]
688 fn test_validate_pad_basic() {
689 let directives = vec![
690 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
691 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
692 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
693 Directive::Balance(Balance::new(
694 date(2024, 1, 2),
695 "Assets:Bank",
696 Amount::new(dec!(1000.00), "USD"),
697 )),
698 ];
699
700 let errors = validate(&directives);
701 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
703 }
704
705 #[test]
706 fn test_validate_pad_with_existing_balance() {
707 let directives = vec![
708 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
709 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
710 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
711 Directive::Transaction(
713 Transaction::new(date(2024, 1, 5), "Initial deposit")
714 .with_posting(Posting::new(
715 "Assets:Bank",
716 Amount::new(dec!(500.00), "USD"),
717 ))
718 .with_posting(Posting::new(
719 "Income:Salary",
720 Amount::new(dec!(-500.00), "USD"),
721 )),
722 ),
723 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
725 Directive::Balance(Balance::new(
726 date(2024, 1, 15),
727 "Assets:Bank",
728 Amount::new(dec!(1000.00), "USD"), )),
730 ];
731
732 let errors = validate(&directives);
733 assert!(
735 errors.is_empty(),
736 "Pad should add missing amount: {errors:?}"
737 );
738 }
739
740 #[test]
741 fn test_validate_pad_account_not_open() {
742 let directives = vec![
743 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
744 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
746 ];
747
748 let errors = validate(&directives);
749 assert!(
750 errors
751 .iter()
752 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
753 "Should error for pad on unopened account"
754 );
755 }
756
757 #[test]
758 fn test_validate_pad_source_not_open() {
759 let directives = vec![
760 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
761 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
763 ];
764
765 let errors = validate(&directives);
766 assert!(
767 errors.iter().any(
768 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
769 ),
770 "Should error for pad with unopened source account"
771 );
772 }
773
774 #[test]
775 fn test_validate_pad_negative_adjustment() {
776 let directives = vec![
778 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
779 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
780 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
781 Directive::Transaction(
783 Transaction::new(date(2024, 1, 5), "Big deposit")
784 .with_posting(Posting::new(
785 "Assets:Bank",
786 Amount::new(dec!(2000.00), "USD"),
787 ))
788 .with_posting(Posting::new(
789 "Income:Salary",
790 Amount::new(dec!(-2000.00), "USD"),
791 )),
792 ),
793 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
795 Directive::Balance(Balance::new(
796 date(2024, 1, 15),
797 "Assets:Bank",
798 Amount::new(dec!(1000.00), "USD"), )),
800 ];
801
802 let errors = validate(&directives);
803 assert!(
804 errors.is_empty(),
805 "Pad should handle negative adjustment: {errors:?}"
806 );
807 }
808
809 #[test]
810 fn test_validate_insufficient_units() {
811 use rustledger_core::CostSpec;
812
813 let cost_spec = CostSpec::empty()
814 .with_number_per(dec!(150))
815 .with_currency("USD");
816
817 let directives = vec![
818 Directive::Open(
819 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
820 ),
821 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
822 Directive::Transaction(
824 Transaction::new(date(2024, 1, 15), "Buy")
825 .with_posting(
826 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
827 .with_cost(cost_spec.clone()),
828 )
829 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
830 ),
831 Directive::Transaction(
833 Transaction::new(date(2024, 6, 1), "Sell too many")
834 .with_posting(
835 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
836 .with_cost(cost_spec),
837 )
838 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
839 ),
840 ];
841
842 let errors = validate(&directives);
843 assert!(
844 errors
845 .iter()
846 .any(|e| e.code == ErrorCode::InsufficientUnits),
847 "Should error for insufficient units: {errors:?}"
848 );
849 }
850
851 #[test]
852 fn test_validate_no_matching_lot() {
853 use rustledger_core::CostSpec;
854
855 let directives = vec![
856 Directive::Open(
857 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
858 ),
859 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
860 Directive::Transaction(
862 Transaction::new(date(2024, 1, 15), "Buy")
863 .with_posting(
864 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
865 CostSpec::empty()
866 .with_number_per(dec!(150))
867 .with_currency("USD"),
868 ),
869 )
870 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
871 ),
872 Directive::Transaction(
874 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
875 .with_posting(
876 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
877 CostSpec::empty()
878 .with_number_per(dec!(160))
879 .with_currency("USD"),
880 ),
881 )
882 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
883 ),
884 ];
885
886 let errors = validate(&directives);
887 assert!(
888 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
889 "Should error for no matching lot: {errors:?}"
890 );
891 }
892
893 #[test]
894 fn test_validate_multiple_lot_match_uses_fifo() {
895 use rustledger_core::CostSpec;
898
899 let cost_spec = CostSpec::empty()
900 .with_number_per(dec!(150))
901 .with_currency("USD");
902
903 let directives = vec![
904 Directive::Open(
905 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
906 ),
907 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
908 Directive::Transaction(
910 Transaction::new(date(2024, 1, 15), "Buy lot 1")
911 .with_posting(
912 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
913 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
914 )
915 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
916 ),
917 Directive::Transaction(
919 Transaction::new(date(2024, 2, 15), "Buy lot 2")
920 .with_posting(
921 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
922 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
923 )
924 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
925 ),
926 Directive::Transaction(
928 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
929 .with_posting(
930 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
931 .with_cost(cost_spec),
932 )
933 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
934 ),
935 ];
936
937 let errors = validate(&directives);
938 let booking_errors: Vec<_> = errors
940 .iter()
941 .filter(|e| {
942 matches!(
943 e.code,
944 ErrorCode::InsufficientUnits
945 | ErrorCode::NoMatchingLot
946 | ErrorCode::AmbiguousLotMatch
947 )
948 })
949 .collect();
950 assert!(
951 booking_errors.is_empty(),
952 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
953 );
954 }
955
956 #[test]
957 fn test_validate_successful_booking() {
958 use rustledger_core::CostSpec;
959
960 let cost_spec = CostSpec::empty()
961 .with_number_per(dec!(150))
962 .with_currency("USD");
963
964 let directives = vec![
965 Directive::Open(
966 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
967 ),
968 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
969 Directive::Transaction(
971 Transaction::new(date(2024, 1, 15), "Buy")
972 .with_posting(
973 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
974 .with_cost(cost_spec.clone()),
975 )
976 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
977 ),
978 Directive::Transaction(
980 Transaction::new(date(2024, 6, 1), "Sell")
981 .with_posting(
982 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
983 .with_cost(cost_spec),
984 )
985 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
986 ),
987 ];
988
989 let errors = validate(&directives);
990 let booking_errors: Vec<_> = errors
992 .iter()
993 .filter(|e| {
994 matches!(
995 e.code,
996 ErrorCode::InsufficientUnits
997 | ErrorCode::NoMatchingLot
998 | ErrorCode::AmbiguousLotMatch
999 )
1000 })
1001 .collect();
1002 assert!(
1003 booking_errors.is_empty(),
1004 "Should have no booking errors: {booking_errors:?}"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_validate_account_already_open() {
1010 let directives = vec![
1011 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1012 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1014
1015 let errors = validate(&directives);
1016 assert!(
1017 errors
1018 .iter()
1019 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1020 "Should error for duplicate open: {errors:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_validate_account_close_not_empty() {
1026 let directives = vec![
1027 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1028 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1029 Directive::Transaction(
1030 Transaction::new(date(2024, 1, 15), "Deposit")
1031 .with_posting(Posting::new(
1032 "Assets:Bank",
1033 Amount::new(dec!(100.00), "USD"),
1034 ))
1035 .with_posting(Posting::new(
1036 "Income:Salary",
1037 Amount::new(dec!(-100.00), "USD"),
1038 )),
1039 ),
1040 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1042
1043 let errors = validate(&directives);
1044 assert!(
1045 errors
1046 .iter()
1047 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1048 "Should warn for closing account with balance: {errors:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_validate_no_postings_allowed() {
1054 let directives = vec![
1057 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1058 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1059 ];
1060
1061 let errors = validate(&directives);
1062 assert!(
1063 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1064 "Should NOT error for transaction with no postings: {errors:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_validate_single_posting() {
1070 let directives = vec![
1071 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1072 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1073 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1074 )),
1075 ];
1076
1077 let errors = validate(&directives);
1078 assert!(
1079 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1080 "Should warn for transaction with single posting: {errors:?}"
1081 );
1082 assert!(ErrorCode::SinglePosting.is_warning());
1084 }
1085
1086 #[test]
1087 fn test_validate_pad_without_balance() {
1088 let directives = vec![
1089 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1090 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1091 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1092 ];
1094
1095 let errors = validate(&directives);
1096 assert!(
1097 errors
1098 .iter()
1099 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1100 "Should error for pad without subsequent balance: {errors:?}"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_validate_multiple_pads_for_balance() {
1106 let directives = vec![
1107 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1108 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1109 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1110 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1112 date(2024, 1, 3),
1113 "Assets:Bank",
1114 Amount::new(dec!(1000.00), "USD"),
1115 )),
1116 ];
1117
1118 let errors = validate(&directives);
1119 assert!(
1120 errors
1121 .iter()
1122 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1123 "Should error for multiple pads before balance: {errors:?}"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_error_severity() {
1129 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1131 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1132 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1133
1134 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1136 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1137 assert_eq!(
1138 ErrorCode::AccountCloseNotEmpty.severity(),
1139 Severity::Warning
1140 );
1141
1142 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1144 }
1145
1146 #[test]
1147 fn test_validate_invalid_account_name() {
1148 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1150
1151 let errors = validate(&directives);
1152 assert!(
1153 errors
1154 .iter()
1155 .any(|e| e.code == ErrorCode::InvalidAccountName),
1156 "Should error for invalid account root: {errors:?}"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_validate_account_lowercase_component() {
1162 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1164
1165 let errors = validate(&directives);
1166 assert!(
1167 errors
1168 .iter()
1169 .any(|e| e.code == ErrorCode::InvalidAccountName),
1170 "Should error for lowercase component: {errors:?}"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_validate_valid_account_names() {
1176 let valid_names = [
1178 "Assets:Bank",
1179 "Assets:Bank:Checking",
1180 "Liabilities:CreditCard",
1181 "Equity:Opening-Balances",
1182 "Income:Salary2024",
1183 "Expenses:Food:Restaurant",
1184 "Assets:401k", "Assets:CORP✨", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Test💰Account", "Assets:€uro", ];
1192
1193 for name in valid_names {
1194 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1195
1196 let errors = validate(&directives);
1197 let name_errors: Vec<_> = errors
1198 .iter()
1199 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1200 .collect();
1201 assert!(
1202 name_errors.is_empty(),
1203 "Should accept valid account name '{name}': {name_errors:?}"
1204 );
1205 }
1206 }
1207}