1#![forbid(unsafe_code)]
46#![warn(missing_docs)]
47
48use chrono::{Local, NaiveDate};
49use rayon::prelude::*;
50use rust_decimal::Decimal;
51use rustledger_core::{
52 Amount, Balance, BookingMethod, Close, Directive, Document, InternedStr, Inventory, Note, Open,
53 Pad, Position, Posting, Transaction,
54};
55use rustledger_parser::{Span, Spanned};
56use std::collections::{HashMap, HashSet};
57use std::path::Path;
58use thiserror::Error;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum ErrorCode {
65 AccountNotOpen,
68 AccountAlreadyOpen,
70 AccountClosed,
72 AccountCloseNotEmpty,
74 InvalidAccountName,
76
77 BalanceAssertionFailed,
80 BalanceToleranceExceeded,
82 PadWithoutBalance,
84 MultiplePadForBalance,
86
87 TransactionUnbalanced,
90 MultipleInterpolation,
92 NoPostings,
94 SinglePosting,
96
97 NoMatchingLot,
100 InsufficientUnits,
102 AmbiguousLotMatch,
104 NegativeInventory,
106
107 UndeclaredCurrency,
110 CurrencyNotAllowed,
112
113 DuplicateMetadataKey,
116 InvalidMetadataValue,
118
119 UnknownOption,
122 InvalidOptionValue,
124 DuplicateOption,
126
127 DocumentNotFound,
130
131 DateOutOfOrder,
134 FutureDate,
136}
137
138impl ErrorCode {
139 #[must_use]
141 pub const fn code(&self) -> &'static str {
142 match self {
143 Self::AccountNotOpen => "E1001",
145 Self::AccountAlreadyOpen => "E1002",
146 Self::AccountClosed => "E1003",
147 Self::AccountCloseNotEmpty => "E1004",
148 Self::InvalidAccountName => "E1005",
149 Self::BalanceAssertionFailed => "E2001",
151 Self::BalanceToleranceExceeded => "E2002",
152 Self::PadWithoutBalance => "E2003",
153 Self::MultiplePadForBalance => "E2004",
154 Self::TransactionUnbalanced => "E3001",
156 Self::MultipleInterpolation => "E3002",
157 Self::NoPostings => "E3003",
158 Self::SinglePosting => "E3004",
159 Self::NoMatchingLot => "E4001",
161 Self::InsufficientUnits => "E4002",
162 Self::AmbiguousLotMatch => "E4003",
163 Self::NegativeInventory => "E4004",
164 Self::UndeclaredCurrency => "E5001",
166 Self::CurrencyNotAllowed => "E5002",
167 Self::DuplicateMetadataKey => "E6001",
169 Self::InvalidMetadataValue => "E6002",
170 Self::UnknownOption => "E7001",
172 Self::InvalidOptionValue => "E7002",
173 Self::DuplicateOption => "E7003",
174 Self::DocumentNotFound => "E8001",
176 Self::DateOutOfOrder => "E10001",
178 Self::FutureDate => "E10002",
179 }
180 }
181
182 #[must_use]
184 pub const fn is_warning(&self) -> bool {
185 matches!(
186 self,
187 Self::FutureDate
188 | Self::SinglePosting
189 | Self::AccountCloseNotEmpty
190 | Self::DateOutOfOrder
191 )
192 }
193
194 #[must_use]
196 pub const fn is_info(&self) -> bool {
197 matches!(self, Self::DateOutOfOrder)
198 }
199
200 #[must_use]
202 pub const fn severity(&self) -> Severity {
203 if self.is_info() {
204 Severity::Info
205 } else if self.is_warning() {
206 Severity::Warning
207 } else {
208 Severity::Error
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum Severity {
216 Error,
218 Warning,
220 Info,
222}
223
224impl std::fmt::Display for ErrorCode {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 write!(f, "{}", self.code())
227 }
228}
229
230#[derive(Debug, Clone, Error)]
232#[error("[{code}] {message}")]
233pub struct ValidationError {
234 pub code: ErrorCode,
236 pub message: String,
238 pub date: NaiveDate,
240 pub context: Option<String>,
242 pub span: Option<Span>,
244 pub file_id: Option<u16>,
247}
248
249impl ValidationError {
250 #[must_use]
252 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
253 Self {
254 code,
255 message: message.into(),
256 date,
257 context: None,
258 span: None,
259 file_id: None,
260 }
261 }
262
263 #[must_use]
265 pub fn with_location<T>(
266 code: ErrorCode,
267 message: impl Into<String>,
268 date: NaiveDate,
269 spanned: &Spanned<T>,
270 ) -> Self {
271 Self {
272 code,
273 message: message.into(),
274 date,
275 context: None,
276 span: Some(spanned.span),
277 file_id: Some(spanned.file_id),
278 }
279 }
280
281 #[must_use]
283 pub fn with_context(mut self, context: impl Into<String>) -> Self {
284 self.context = Some(context.into());
285 self
286 }
287
288 #[must_use]
293 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
294 self.span = Some(spanned.span);
295 self.file_id = Some(spanned.file_id);
296 self
297 }
298}
299
300#[derive(Debug, Clone)]
302struct AccountState {
303 opened: NaiveDate,
305 closed: Option<NaiveDate>,
307 currencies: HashSet<InternedStr>,
309 #[allow(dead_code)]
311 booking: BookingMethod,
312}
313
314#[derive(Debug, Clone)]
316pub struct ValidationOptions {
317 pub require_commodities: bool,
319 pub check_documents: bool,
321 pub warn_future_dates: bool,
323 pub document_base: Option<std::path::PathBuf>,
325 pub account_types: Vec<String>,
328 pub infer_tolerance_from_cost: bool,
331 pub tolerance_multiplier: Decimal,
334}
335
336impl Default for ValidationOptions {
337 fn default() -> Self {
338 Self {
339 require_commodities: false,
340 check_documents: true, warn_future_dates: false,
342 document_base: None,
343 account_types: vec![
344 "Assets".to_string(),
345 "Liabilities".to_string(),
346 "Equity".to_string(),
347 "Income".to_string(),
348 "Expenses".to_string(),
349 ],
350 infer_tolerance_from_cost: true,
352 tolerance_multiplier: Decimal::new(5, 1), }
354 }
355}
356
357#[derive(Debug, Clone)]
359struct PendingPad {
360 source_account: InternedStr,
362 date: NaiveDate,
364 used: bool,
366}
367
368#[derive(Debug, Default)]
370pub struct LedgerState {
371 accounts: HashMap<InternedStr, AccountState>,
373 inventories: HashMap<InternedStr, Inventory>,
375 commodities: HashSet<InternedStr>,
377 pending_pads: HashMap<InternedStr, Vec<PendingPad>>,
379 options: ValidationOptions,
381 last_date: Option<NaiveDate>,
383}
384
385impl LedgerState {
386 #[must_use]
388 pub fn new() -> Self {
389 Self::default()
390 }
391
392 #[must_use]
394 pub fn with_options(options: ValidationOptions) -> Self {
395 Self {
396 options,
397 ..Default::default()
398 }
399 }
400
401 pub const fn set_require_commodities(&mut self, require: bool) {
403 self.options.require_commodities = require;
404 }
405
406 pub const fn set_check_documents(&mut self, check: bool) {
408 self.options.check_documents = check;
409 }
410
411 pub const fn set_warn_future_dates(&mut self, warn: bool) {
413 self.options.warn_future_dates = warn;
414 }
415
416 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
418 self.options.document_base = Some(base.into());
419 }
420
421 #[must_use]
423 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
424 self.inventories.get(account)
425 }
426
427 pub fn accounts(&self) -> impl Iterator<Item = &str> {
429 self.accounts.keys().map(InternedStr::as_str)
430 }
431}
432
433pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
437 validate_with_options(directives, ValidationOptions::default())
438}
439
440pub fn validate_with_options(
444 directives: &[Directive],
445 options: ValidationOptions,
446) -> Vec<ValidationError> {
447 let mut state = LedgerState::with_options(options);
448 let mut errors = Vec::new();
449
450 let today = Local::now().date_naive();
451
452 let mut sorted: Vec<&Directive> = directives.iter().collect();
455 sorted.par_sort_by(|a, b| {
456 a.date()
457 .cmp(&b.date())
458 .then_with(|| a.priority().cmp(&b.priority()))
459 });
460
461 for directive in sorted {
462 let date = directive.date();
463
464 if let Some(last) = state.last_date {
466 if date < last {
467 errors.push(ValidationError::new(
468 ErrorCode::DateOutOfOrder,
469 format!("Directive date {date} is before previous directive {last}"),
470 date,
471 ));
472 }
473 }
474 state.last_date = Some(date);
475
476 if state.options.warn_future_dates && date > today {
478 errors.push(ValidationError::new(
479 ErrorCode::FutureDate,
480 format!("Entry dated in the future: {date}"),
481 date,
482 ));
483 }
484
485 match directive {
486 Directive::Open(open) => {
487 validate_open(&mut state, open, &mut errors);
488 }
489 Directive::Close(close) => {
490 validate_close(&mut state, close, &mut errors);
491 }
492 Directive::Transaction(txn) => {
493 validate_transaction(&mut state, txn, &mut errors);
494 }
495 Directive::Balance(bal) => {
496 validate_balance(&mut state, bal, &mut errors);
497 }
498 Directive::Commodity(comm) => {
499 state.commodities.insert(comm.currency.clone());
500 }
501 Directive::Pad(pad) => {
502 validate_pad(&mut state, pad, &mut errors);
503 }
504 Directive::Document(doc) => {
505 validate_document(&state, doc, &mut errors);
506 }
507 Directive::Note(note) => {
508 validate_note(&state, note, &mut errors);
509 }
510 _ => {}
511 }
512 }
513
514 for (target_account, pads) in &state.pending_pads {
516 for pad in pads {
517 if !pad.used {
518 errors.push(
519 ValidationError::new(
520 ErrorCode::PadWithoutBalance,
521 "Unused Pad entry".to_string(),
522 pad.date,
523 )
524 .with_context(format!(
525 " {} pad {} {}",
526 pad.date, target_account, pad.source_account
527 )),
528 );
529 }
530 }
531 }
532
533 errors
534}
535
536pub fn validate_spanned_with_options(
545 directives: &[Spanned<Directive>],
546 options: ValidationOptions,
547) -> Vec<ValidationError> {
548 let mut state = LedgerState::with_options(options);
549 let mut errors = Vec::new();
550
551 let today = Local::now().date_naive();
552
553 let mut sorted: Vec<&Spanned<Directive>> = directives.iter().collect();
555 sorted.par_sort_by(|a, b| {
556 a.value
557 .date()
558 .cmp(&b.value.date())
559 .then_with(|| a.value.priority().cmp(&b.value.priority()))
560 });
561
562 for spanned in sorted {
563 let directive = &spanned.value;
564 let date = directive.date();
565
566 if let Some(last) = state.last_date {
568 if date < last {
569 errors.push(ValidationError::with_location(
570 ErrorCode::DateOutOfOrder,
571 format!("Directive date {date} is before previous directive {last}"),
572 date,
573 spanned,
574 ));
575 }
576 }
577 state.last_date = Some(date);
578
579 if state.options.warn_future_dates && date > today {
581 errors.push(ValidationError::with_location(
582 ErrorCode::FutureDate,
583 format!("Entry dated in the future: {date}"),
584 date,
585 spanned,
586 ));
587 }
588
589 let error_count_before = errors.len();
591
592 match directive {
593 Directive::Open(open) => {
594 validate_open(&mut state, open, &mut errors);
595 }
596 Directive::Close(close) => {
597 validate_close(&mut state, close, &mut errors);
598 }
599 Directive::Transaction(txn) => {
600 validate_transaction(&mut state, txn, &mut errors);
601 }
602 Directive::Balance(bal) => {
603 validate_balance(&mut state, bal, &mut errors);
604 }
605 Directive::Commodity(comm) => {
606 state.commodities.insert(comm.currency.clone());
607 }
608 Directive::Pad(pad) => {
609 validate_pad(&mut state, pad, &mut errors);
610 }
611 Directive::Document(doc) => {
612 validate_document(&state, doc, &mut errors);
613 }
614 Directive::Note(note) => {
615 validate_note(&state, note, &mut errors);
616 }
617 _ => {}
618 }
619
620 for error in errors.iter_mut().skip(error_count_before) {
622 if error.span.is_none() {
623 error.span = Some(spanned.span);
624 error.file_id = Some(spanned.file_id);
625 }
626 }
627 }
628
629 for (target_account, pads) in &state.pending_pads {
632 for pad in pads {
633 if !pad.used {
634 errors.push(
635 ValidationError::new(
636 ErrorCode::PadWithoutBalance,
637 "Unused Pad entry".to_string(),
638 pad.date,
639 )
640 .with_context(format!(
641 " {} pad {} {}",
642 pad.date, target_account, pad.source_account
643 )),
644 );
645 }
646 }
647 }
648
649 errors
650}
651
652fn validate_account_name(account: &str, account_types: &[String]) -> Option<String> {
659 if account.is_empty() {
660 return Some("account name is empty".to_string());
661 }
662
663 let parts: Vec<&str> = account.split(':').collect();
664 if parts.is_empty() {
665 return Some("account name has no components".to_string());
666 }
667
668 let root = parts[0];
670 if !account_types.iter().any(|t| t == root) {
671 return Some(format!(
672 "account must start with one of: {}",
673 account_types.join(", ")
674 ));
675 }
676
677 for (i, part) in parts.iter().enumerate() {
679 if part.is_empty() {
680 return Some(format!("component {} is empty", i + 1));
681 }
682
683 let Some(first_char) = part.chars().next() else {
689 return Some(format!("component {} is empty", i + 1));
692 };
693 let is_valid_start = first_char.is_ascii_uppercase()
695 || first_char.is_ascii_digit()
696 || !first_char.is_ascii();
697 if !is_valid_start {
698 return Some(format!(
699 "component '{part}' must start with uppercase letter or digit"
700 ));
701 }
702
703 for c in part.chars().skip(1) {
706 if !c.is_ascii_alphanumeric() && c != '-' && c.is_ascii() {
707 return Some(format!(
708 "component '{part}' contains invalid character '{c}'"
709 ));
710 }
711 }
712 }
713
714 None }
716
717fn validate_open(state: &mut LedgerState, open: &Open, errors: &mut Vec<ValidationError>) {
718 if let Some(reason) = validate_account_name(&open.account, &state.options.account_types) {
720 errors.push(
721 ValidationError::new(
722 ErrorCode::InvalidAccountName,
723 format!("Invalid account name \"{}\": {}", open.account, reason),
724 open.date,
725 )
726 .with_context(open.account.to_string()),
727 );
728 }
730
731 if let Some(existing) = state.accounts.get(&open.account) {
733 errors.push(ValidationError::new(
734 ErrorCode::AccountAlreadyOpen,
735 format!(
736 "Account {} is already open (opened on {})",
737 open.account, existing.opened
738 ),
739 open.date,
740 ));
741 return;
742 }
743
744 let booking = open
745 .booking
746 .as_ref()
747 .and_then(|b| b.parse::<BookingMethod>().ok())
748 .unwrap_or_default();
749
750 state.accounts.insert(
751 open.account.clone(),
752 AccountState {
753 opened: open.date,
754 closed: None,
755 currencies: open.currencies.iter().cloned().collect(),
756 booking,
757 },
758 );
759
760 state
761 .inventories
762 .insert(open.account.clone(), Inventory::new());
763}
764
765fn validate_close(state: &mut LedgerState, close: &Close, errors: &mut Vec<ValidationError>) {
766 match state.accounts.get_mut(&close.account) {
767 Some(account_state) => {
768 if account_state.closed.is_some() {
769 errors.push(ValidationError::new(
770 ErrorCode::AccountClosed,
771 format!("Account {} already closed", close.account),
772 close.date,
773 ));
774 } else {
775 if let Some(inv) = state.inventories.get(&close.account) {
777 if !inv.is_empty() {
778 let positions: Vec<String> = inv
779 .positions()
780 .iter()
781 .map(|p| format!("{} {}", p.units.number, p.units.currency))
782 .collect();
783 errors.push(
784 ValidationError::new(
785 ErrorCode::AccountCloseNotEmpty,
786 format!(
787 "Cannot close account {} with non-zero balance",
788 close.account
789 ),
790 close.date,
791 )
792 .with_context(format!("balance: {}", positions.join(", "))),
793 );
794 }
795 }
796 account_state.closed = Some(close.date);
797 }
798 }
799 None => {
800 errors.push(ValidationError::new(
801 ErrorCode::AccountNotOpen,
802 format!("Account {} was never opened", close.account),
803 close.date,
804 ));
805 }
806 }
807}
808
809fn validate_transaction(
810 state: &mut LedgerState,
811 txn: &Transaction,
812 errors: &mut Vec<ValidationError>,
813) {
814 if !validate_transaction_structure(txn, errors) {
816 return; }
818
819 validate_posting_accounts(state, txn, errors);
821
822 validate_transaction_balance(txn, &state.options, errors);
824
825 update_inventories(state, txn, errors);
827}
828
829fn validate_transaction_structure(txn: &Transaction, errors: &mut Vec<ValidationError>) -> bool {
835 if txn.postings.is_empty() {
836 return false;
839 }
840
841 if txn.postings.len() == 1 {
843 errors.push(ValidationError::new(
844 ErrorCode::SinglePosting,
845 "Transaction has only one posting".to_string(),
846 txn.date,
847 ));
848 }
849
850 true
851}
852
853fn validate_posting_accounts(
855 state: &LedgerState,
856 txn: &Transaction,
857 errors: &mut Vec<ValidationError>,
858) {
859 for posting in &txn.postings {
860 match state.accounts.get(&posting.account) {
861 Some(account_state) => {
862 validate_account_lifecycle(txn, posting, account_state, errors);
863 validate_posting_currency(state, txn, posting, account_state, errors);
864 }
865 None => {
866 errors.push(ValidationError::new(
867 ErrorCode::AccountNotOpen,
868 format!("Account {} was never opened", posting.account),
869 txn.date,
870 ));
871 }
872 }
873 }
874}
875
876fn validate_account_lifecycle(
878 txn: &Transaction,
879 posting: &Posting,
880 account_state: &AccountState,
881 errors: &mut Vec<ValidationError>,
882) {
883 if txn.date < account_state.opened {
884 errors.push(ValidationError::new(
885 ErrorCode::AccountNotOpen,
886 format!(
887 "Account {} used on {} but not opened until {}",
888 posting.account, txn.date, account_state.opened
889 ),
890 txn.date,
891 ));
892 }
893
894 if let Some(closed) = account_state.closed {
895 if txn.date >= closed {
896 errors.push(ValidationError::new(
897 ErrorCode::AccountClosed,
898 format!(
899 "Account {} used on {} but was closed on {}",
900 posting.account, txn.date, closed
901 ),
902 txn.date,
903 ));
904 }
905 }
906}
907
908fn validate_posting_currency(
910 state: &LedgerState,
911 txn: &Transaction,
912 posting: &Posting,
913 account_state: &AccountState,
914 errors: &mut Vec<ValidationError>,
915) {
916 let Some(units) = posting.amount() else {
917 return;
918 };
919
920 if !account_state.currencies.is_empty() && !account_state.currencies.contains(&units.currency) {
922 errors.push(ValidationError::new(
923 ErrorCode::CurrencyNotAllowed,
924 format!(
925 "Currency {} not allowed in account {}",
926 units.currency, posting.account
927 ),
928 txn.date,
929 ));
930 }
931
932 if state.options.require_commodities && !state.commodities.contains(&units.currency) {
934 errors.push(ValidationError::new(
935 ErrorCode::UndeclaredCurrency,
936 format!("Currency {} not declared", units.currency),
937 txn.date,
938 ));
939 }
940}
941
942fn validate_transaction_balance(
949 txn: &Transaction,
950 options: &ValidationOptions,
951 errors: &mut Vec<ValidationError>,
952) {
953 let has_empty_cost_spec = txn.postings.iter().any(|p| {
958 if let Some(cost) = &p.cost {
959 cost.number_per.is_none() && cost.number_total.is_none()
961 } else {
962 false
963 }
964 });
965 if has_empty_cost_spec {
966 return; }
968
969 let residuals = rustledger_booking::calculate_residual(txn);
970
971 let tolerances = calculate_tolerances(txn, options);
973
974 for (currency, residual) in residuals {
975 let tolerance = tolerances
977 .get(currency.as_str())
978 .copied()
979 .unwrap_or_else(|| Decimal::new(5, 3));
980
981 if residual.abs() > tolerance {
982 errors.push(ValidationError::new(
983 ErrorCode::TransactionUnbalanced,
984 format!("Transaction does not balance: residual {residual} {currency}"),
985 txn.date,
986 ));
987 }
988 }
989}
990
991fn decimal_quantum(value: Decimal) -> Decimal {
994 let scale = value.scale();
995 if scale == 0 {
996 Decimal::ONE
997 } else {
998 Decimal::new(1, scale)
999 }
1000}
1001
1002fn calculate_tolerances(
1010 txn: &Transaction,
1011 options: &ValidationOptions,
1012) -> HashMap<String, Decimal> {
1013 let mut tolerances: HashMap<String, Decimal> = HashMap::new();
1014
1015 for posting in &txn.postings {
1017 if let Some(units) = posting.amount() {
1018 let quantum = decimal_quantum(units.number);
1019 let base_tolerance = quantum * options.tolerance_multiplier;
1021
1022 tolerances
1023 .entry(units.currency.to_string())
1024 .and_modify(|t| *t = (*t).max(base_tolerance))
1025 .or_insert(base_tolerance);
1026 }
1027 }
1028
1029 if options.infer_tolerance_from_cost {
1031 for posting in &txn.postings {
1032 if let (Some(units), Some(cost_spec)) = (posting.amount(), &posting.cost) {
1033 if let Some(cost_per_unit) = cost_spec.number_per {
1035 if let Some(cost_currency) = &cost_spec.currency {
1037 let units_quantum = decimal_quantum(units.number);
1039 let cost_tolerance =
1040 units_quantum * cost_per_unit * options.tolerance_multiplier;
1041
1042 tolerances
1044 .entry(cost_currency.to_string())
1045 .and_modify(|t| *t = (*t).max(cost_tolerance))
1046 .or_insert(cost_tolerance);
1047 }
1048 }
1049 }
1050 }
1051 }
1052
1053 tolerances
1054}
1055
1056fn update_inventories(
1058 state: &mut LedgerState,
1059 txn: &Transaction,
1060 errors: &mut Vec<ValidationError>,
1061) {
1062 for posting in &txn.postings {
1063 let Some(units) = posting.amount() else {
1064 continue;
1065 };
1066 let Some(inv) = state.inventories.get_mut(&posting.account) else {
1067 continue;
1068 };
1069
1070 let booking_method = state
1071 .accounts
1072 .get(&posting.account)
1073 .map(|a| a.booking)
1074 .unwrap_or_default();
1075
1076 let is_reduction = units.number.is_sign_negative() && posting.cost.is_some();
1077
1078 if is_reduction {
1079 process_inventory_reduction(inv, posting, units, booking_method, txn, errors);
1080 } else {
1081 process_inventory_addition(inv, posting, units, txn);
1082 }
1083 }
1084}
1085
1086fn process_inventory_reduction(
1088 inv: &mut Inventory,
1089 posting: &Posting,
1090 units: &Amount,
1091 booking_method: BookingMethod,
1092 txn: &Transaction,
1093 errors: &mut Vec<ValidationError>,
1094) {
1095 match inv.reduce(units, posting.cost.as_ref(), booking_method) {
1096 Ok(_) => {}
1097 Err(rustledger_core::BookingError::InsufficientUnits {
1098 requested,
1099 available,
1100 ..
1101 }) => {
1102 errors.push(
1103 ValidationError::new(
1104 ErrorCode::InsufficientUnits,
1105 format!(
1106 "Insufficient units in {}: requested {}, available {}",
1107 posting.account, requested, available
1108 ),
1109 txn.date,
1110 )
1111 .with_context(format!("currency: {}", units.currency)),
1112 );
1113 }
1114 Err(rustledger_core::BookingError::NoMatchingLot { currency, .. }) => {
1115 let has_positive_lots = inv
1123 .positions()
1124 .iter()
1125 .any(|p| p.units.currency == units.currency && p.units.number > Decimal::ZERO);
1126
1127 if booking_method == BookingMethod::Strict && !has_positive_lots {
1128 if let Some(cost_spec) = &posting.cost {
1129 let cost_number = cost_spec
1131 .number_per
1132 .or_else(|| cost_spec.number_total.map(|t| t / units.number.abs()));
1133
1134 let cost_currency = cost_spec.currency.clone().or_else(|| {
1136 posting.price.as_ref().and_then(|p| match p {
1138 rustledger_core::PriceAnnotation::Unit(a)
1139 | rustledger_core::PriceAnnotation::Total(a) => {
1140 Some(a.currency.clone())
1141 }
1142 rustledger_core::PriceAnnotation::UnitIncomplete(inc)
1143 | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
1144 inc.as_amount().map(|a| a.currency.clone())
1145 }
1146 _ => None,
1147 })
1148 });
1149
1150 if let (Some(number), Some(curr)) = (cost_number, cost_currency) {
1151 let cost = rustledger_core::Cost::new(number, curr)
1153 .with_date(cost_spec.date.unwrap_or(txn.date));
1154 let cost = if let Some(label) = &cost_spec.label {
1155 cost.with_label(label.clone())
1156 } else {
1157 cost
1158 };
1159 let position = rustledger_core::Position::with_cost(units.clone(), cost);
1160 inv.add(position);
1161 return; }
1163 }
1164 }
1165 errors.push(
1167 ValidationError::new(
1168 ErrorCode::NoMatchingLot,
1169 format!("No matching lot for {} in {}", currency, posting.account),
1170 txn.date,
1171 )
1172 .with_context(format!("cost spec: {:?}", posting.cost)),
1173 );
1174 }
1175 Err(rustledger_core::BookingError::AmbiguousMatch {
1176 currency,
1177 num_matches,
1178 }) => {
1179 errors.push(
1180 ValidationError::new(
1181 ErrorCode::AmbiguousLotMatch,
1182 format!(
1183 "Ambiguous lot match for {}: {} lots match in {}",
1184 currency, num_matches, posting.account
1185 ),
1186 txn.date,
1187 )
1188 .with_context("Specify cost, date, or label to disambiguate".to_string()),
1189 );
1190 }
1191 Err(rustledger_core::BookingError::CurrencyMismatch { .. }) => {
1192 }
1194 }
1195}
1196
1197fn process_inventory_addition(
1199 inv: &mut Inventory,
1200 posting: &Posting,
1201 units: &Amount,
1202 txn: &Transaction,
1203) {
1204 let position = if let Some(cost_spec) = &posting.cost {
1205 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
1206 rustledger_core::Position::with_cost(units.clone(), cost)
1207 } else {
1208 rustledger_core::Position::simple(units.clone())
1209 }
1210 } else {
1211 rustledger_core::Position::simple(units.clone())
1212 };
1213
1214 inv.add(position);
1215}
1216
1217fn validate_pad(state: &mut LedgerState, pad: &Pad, errors: &mut Vec<ValidationError>) {
1218 if !state.accounts.contains_key(&pad.account) {
1220 errors.push(ValidationError::new(
1221 ErrorCode::AccountNotOpen,
1222 format!("Pad target account {} was never opened", pad.account),
1223 pad.date,
1224 ));
1225 return;
1226 }
1227
1228 if !state.accounts.contains_key(&pad.source_account) {
1230 errors.push(ValidationError::new(
1231 ErrorCode::AccountNotOpen,
1232 format!("Pad source account {} was never opened", pad.source_account),
1233 pad.date,
1234 ));
1235 return;
1236 }
1237
1238 let pending_pad = PendingPad {
1240 source_account: pad.source_account.clone(),
1241 date: pad.date,
1242 used: false,
1243 };
1244 state
1245 .pending_pads
1246 .entry(pad.account.clone())
1247 .or_default()
1248 .push(pending_pad);
1249}
1250
1251fn validate_balance(state: &mut LedgerState, bal: &Balance, errors: &mut Vec<ValidationError>) {
1252 if !state.accounts.contains_key(&bal.account) {
1254 errors.push(ValidationError::new(
1255 ErrorCode::AccountNotOpen,
1256 format!("Account {} was never opened", bal.account),
1257 bal.date,
1258 ));
1259 return;
1260 }
1261
1262 if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
1265 if pending_pads.len() > 1 && !pending_pads.iter().any(|p| p.used) {
1267 errors.push(
1268 ValidationError::new(
1269 ErrorCode::MultiplePadForBalance,
1270 format!(
1271 "Multiple pad directives for {} {} before balance assertion",
1272 bal.account, bal.amount.currency
1273 ),
1274 bal.date,
1275 )
1276 .with_context(format!(
1277 "pad dates: {}",
1278 pending_pads
1279 .iter()
1280 .map(|p| p.date.to_string())
1281 .collect::<Vec<_>>()
1282 .join(", ")
1283 )),
1284 );
1285 }
1286
1287 if let Some(pending_pad) = pending_pads.last_mut() {
1289 let mut actual = Decimal::ZERO;
1292 let account_prefix = format!("{}:", bal.account);
1293 for (account, inv) in &state.inventories {
1294 if account == &bal.account || account.starts_with(&account_prefix) {
1295 actual += inv.units(&bal.amount.currency);
1296 }
1297 }
1298 {
1299 let expected = bal.amount.number;
1300 let difference = expected - actual;
1301
1302 if difference != Decimal::ZERO {
1303 if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
1305 target_inv.add(Position::simple(Amount::new(
1306 difference,
1307 &bal.amount.currency,
1308 )));
1309 }
1310
1311 if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
1313 {
1314 source_inv.add(Position::simple(Amount::new(
1315 -difference,
1316 &bal.amount.currency,
1317 )));
1318 }
1319
1320 pending_pad.used = true;
1322 }
1323 }
1324 }
1325 return;
1327 }
1328
1329 let mut actual = Decimal::ZERO;
1333 let account_prefix = format!("{}:", bal.account);
1334 for (account, inv) in &state.inventories {
1335 if account == &bal.account || account.starts_with(&account_prefix) {
1337 actual += inv.units(&bal.amount.currency);
1338 }
1339 }
1340
1341 if actual != Decimal::ZERO || state.inventories.contains_key(&bal.account) {
1342 let expected = bal.amount.number;
1343 let difference = (actual - expected).abs();
1344
1345 let (tolerance, is_explicit) = if let Some(t) = bal.tolerance {
1347 (t, true)
1348 } else {
1349 (bal.amount.inferred_tolerance(), false)
1350 };
1351
1352 if difference > tolerance {
1353 let error_code = if is_explicit {
1355 ErrorCode::BalanceToleranceExceeded
1356 } else {
1357 ErrorCode::BalanceAssertionFailed
1358 };
1359
1360 let message = if is_explicit {
1361 format!(
1362 "Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
1363 bal.account,
1364 expected,
1365 bal.amount.currency,
1366 tolerance,
1367 actual,
1368 bal.amount.currency,
1369 difference
1370 )
1371 } else {
1372 format!(
1373 "Balance assertion failed for {}: expected {} {}, got {} {}",
1374 bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1375 )
1376 };
1377
1378 errors.push(
1379 ValidationError::new(error_code, message, bal.date)
1380 .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1381 );
1382 }
1383 }
1384}
1385
1386fn validate_note(state: &LedgerState, note: &Note, errors: &mut Vec<ValidationError>) {
1390 if !state.accounts.contains_key(¬e.account) {
1392 errors.push(ValidationError::new(
1393 ErrorCode::AccountNotOpen,
1394 format!("Invalid reference to unknown account '{}'", note.account),
1395 note.date,
1396 ));
1397 }
1398}
1399
1400fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1401 if !state.accounts.contains_key(&doc.account) {
1403 errors.push(ValidationError::new(
1404 ErrorCode::AccountNotOpen,
1405 format!("Invalid reference to unknown account '{}'", doc.account),
1406 doc.date,
1407 ));
1408 }
1409
1410 if state.options.check_documents {
1412 let doc_path = Path::new(&doc.path);
1413
1414 let full_path = if doc_path.is_absolute() {
1415 doc_path.to_path_buf()
1416 } else if let Some(base) = &state.options.document_base {
1417 base.join(doc_path)
1418 } else {
1419 doc_path.to_path_buf()
1420 };
1421
1422 if !full_path.exists() {
1423 errors.push(
1424 ValidationError::new(
1425 ErrorCode::DocumentNotFound,
1426 format!("Document file not found: {}", doc.path),
1427 doc.date,
1428 )
1429 .with_context(format!("resolved path: {}", full_path.display())),
1430 );
1431 }
1432 }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438 use rust_decimal_macros::dec;
1439 use rustledger_core::{Amount, NaiveDate, Posting};
1440
1441 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1442 NaiveDate::from_ymd_opt(year, month, day).unwrap()
1443 }
1444
1445 #[test]
1446 fn test_validate_account_lifecycle() {
1447 let directives = vec![
1448 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1449 Directive::Transaction(
1450 Transaction::new(date(2024, 1, 15), "Test")
1451 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1452 .with_posting(Posting::new(
1453 "Income:Salary",
1454 Amount::new(dec!(-100), "USD"),
1455 )),
1456 ),
1457 ];
1458
1459 let errors = validate(&directives);
1460
1461 assert!(errors
1463 .iter()
1464 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1465 }
1466
1467 #[test]
1468 fn test_validate_account_used_before_open() {
1469 let directives = vec![
1470 Directive::Transaction(
1471 Transaction::new(date(2024, 1, 1), "Test")
1472 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1473 .with_posting(Posting::new(
1474 "Income:Salary",
1475 Amount::new(dec!(-100), "USD"),
1476 )),
1477 ),
1478 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1479 ];
1480
1481 let errors = validate(&directives);
1482
1483 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1484 }
1485
1486 #[test]
1487 fn test_validate_account_used_after_close() {
1488 let directives = vec![
1489 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1490 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1491 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1492 Directive::Transaction(
1493 Transaction::new(date(2024, 7, 1), "Test")
1494 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1495 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1496 ),
1497 ];
1498
1499 let errors = validate(&directives);
1500
1501 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1502 }
1503
1504 #[test]
1505 fn test_validate_balance_assertion() {
1506 let directives = vec![
1507 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1508 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1509 Directive::Transaction(
1510 Transaction::new(date(2024, 1, 15), "Deposit")
1511 .with_posting(Posting::new(
1512 "Assets:Bank",
1513 Amount::new(dec!(1000.00), "USD"),
1514 ))
1515 .with_posting(Posting::new(
1516 "Income:Salary",
1517 Amount::new(dec!(-1000.00), "USD"),
1518 )),
1519 ),
1520 Directive::Balance(Balance::new(
1521 date(2024, 1, 16),
1522 "Assets:Bank",
1523 Amount::new(dec!(1000.00), "USD"),
1524 )),
1525 ];
1526
1527 let errors = validate(&directives);
1528 assert!(errors.is_empty(), "{errors:?}");
1529 }
1530
1531 #[test]
1532 fn test_validate_balance_assertion_failed() {
1533 let directives = vec![
1534 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1535 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1536 Directive::Transaction(
1537 Transaction::new(date(2024, 1, 15), "Deposit")
1538 .with_posting(Posting::new(
1539 "Assets:Bank",
1540 Amount::new(dec!(1000.00), "USD"),
1541 ))
1542 .with_posting(Posting::new(
1543 "Income:Salary",
1544 Amount::new(dec!(-1000.00), "USD"),
1545 )),
1546 ),
1547 Directive::Balance(Balance::new(
1548 date(2024, 1, 16),
1549 "Assets:Bank",
1550 Amount::new(dec!(500.00), "USD"), )),
1552 ];
1553
1554 let errors = validate(&directives);
1555 assert!(
1556 errors
1557 .iter()
1558 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1559 );
1560 }
1561
1562 #[test]
1563 fn test_validate_unbalanced_transaction() {
1564 let directives = vec![
1565 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1566 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1567 Directive::Transaction(
1568 Transaction::new(date(2024, 1, 15), "Unbalanced")
1569 .with_posting(Posting::new(
1570 "Assets:Bank",
1571 Amount::new(dec!(-50.00), "USD"),
1572 ))
1573 .with_posting(Posting::new(
1574 "Expenses:Food",
1575 Amount::new(dec!(40.00), "USD"),
1576 )), ),
1578 ];
1579
1580 let errors = validate(&directives);
1581 assert!(
1582 errors
1583 .iter()
1584 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1585 );
1586 }
1587
1588 #[test]
1589 fn test_validate_currency_not_allowed() {
1590 let directives = vec![
1591 Directive::Open(
1592 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1593 ),
1594 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1595 Directive::Transaction(
1596 Transaction::new(date(2024, 1, 15), "Test")
1597 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
1599 "Income:Salary",
1600 Amount::new(dec!(-100.00), "EUR"),
1601 )),
1602 ),
1603 ];
1604
1605 let errors = validate(&directives);
1606 assert!(
1607 errors
1608 .iter()
1609 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1610 );
1611 }
1612
1613 #[test]
1614 fn test_validate_future_date_warning() {
1615 let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1617
1618 let directives = vec![Directive::Open(Open {
1619 date: future_date,
1620 account: "Assets:Bank".into(),
1621 currencies: vec![],
1622 booking: None,
1623 meta: Default::default(),
1624 })];
1625
1626 let errors = validate(&directives);
1628 assert!(
1629 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1630 "Should not warn about future dates by default"
1631 );
1632
1633 let options = ValidationOptions {
1635 warn_future_dates: true,
1636 ..Default::default()
1637 };
1638 let errors = validate_with_options(&directives, options);
1639 assert!(
1640 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1641 "Should warn about future dates when enabled"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_validate_document_not_found() {
1647 let directives = vec![
1648 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1649 Directive::Document(Document {
1650 date: date(2024, 1, 15),
1651 account: "Assets:Bank".into(),
1652 path: "/nonexistent/path/to/document.pdf".to_string(),
1653 tags: vec![],
1654 links: vec![],
1655 meta: Default::default(),
1656 }),
1657 ];
1658
1659 let errors = validate(&directives);
1661 assert!(
1662 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1663 "Should check documents by default"
1664 );
1665
1666 let options = ValidationOptions {
1668 check_documents: false,
1669 ..Default::default()
1670 };
1671 let errors = validate_with_options(&directives, options);
1672 assert!(
1673 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1674 "Should not report missing document when disabled"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_validate_document_account_not_open() {
1680 let directives = vec![Directive::Document(Document {
1681 date: date(2024, 1, 15),
1682 account: "Assets:Unknown".into(),
1683 path: "receipt.pdf".to_string(),
1684 tags: vec![],
1685 links: vec![],
1686 meta: Default::default(),
1687 })];
1688
1689 let errors = validate(&directives);
1690 assert!(
1691 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1692 "Should error for document on unopened account"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_error_code_is_warning() {
1698 assert!(!ErrorCode::AccountNotOpen.is_warning());
1699 assert!(!ErrorCode::DocumentNotFound.is_warning());
1700 assert!(ErrorCode::FutureDate.is_warning());
1701 }
1702
1703 #[test]
1704 fn test_validate_pad_basic() {
1705 let directives = vec![
1706 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1707 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1708 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1709 Directive::Balance(Balance::new(
1710 date(2024, 1, 2),
1711 "Assets:Bank",
1712 Amount::new(dec!(1000.00), "USD"),
1713 )),
1714 ];
1715
1716 let errors = validate(&directives);
1717 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1719 }
1720
1721 #[test]
1722 fn test_validate_pad_with_existing_balance() {
1723 let directives = vec![
1724 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1725 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1726 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1727 Directive::Transaction(
1729 Transaction::new(date(2024, 1, 5), "Initial deposit")
1730 .with_posting(Posting::new(
1731 "Assets:Bank",
1732 Amount::new(dec!(500.00), "USD"),
1733 ))
1734 .with_posting(Posting::new(
1735 "Income:Salary",
1736 Amount::new(dec!(-500.00), "USD"),
1737 )),
1738 ),
1739 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1741 Directive::Balance(Balance::new(
1742 date(2024, 1, 15),
1743 "Assets:Bank",
1744 Amount::new(dec!(1000.00), "USD"), )),
1746 ];
1747
1748 let errors = validate(&directives);
1749 assert!(
1751 errors.is_empty(),
1752 "Pad should add missing amount: {errors:?}"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_validate_pad_account_not_open() {
1758 let directives = vec![
1759 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1760 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1762 ];
1763
1764 let errors = validate(&directives);
1765 assert!(
1766 errors
1767 .iter()
1768 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1769 "Should error for pad on unopened account"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_validate_pad_source_not_open() {
1775 let directives = vec![
1776 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1777 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1779 ];
1780
1781 let errors = validate(&directives);
1782 assert!(
1783 errors.iter().any(
1784 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1785 ),
1786 "Should error for pad with unopened source account"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_validate_pad_negative_adjustment() {
1792 let directives = vec![
1794 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1795 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1796 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1797 Directive::Transaction(
1799 Transaction::new(date(2024, 1, 5), "Big deposit")
1800 .with_posting(Posting::new(
1801 "Assets:Bank",
1802 Amount::new(dec!(2000.00), "USD"),
1803 ))
1804 .with_posting(Posting::new(
1805 "Income:Salary",
1806 Amount::new(dec!(-2000.00), "USD"),
1807 )),
1808 ),
1809 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1811 Directive::Balance(Balance::new(
1812 date(2024, 1, 15),
1813 "Assets:Bank",
1814 Amount::new(dec!(1000.00), "USD"), )),
1816 ];
1817
1818 let errors = validate(&directives);
1819 assert!(
1820 errors.is_empty(),
1821 "Pad should handle negative adjustment: {errors:?}"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_validate_insufficient_units() {
1827 use rustledger_core::CostSpec;
1828
1829 let cost_spec = CostSpec::empty()
1830 .with_number_per(dec!(150))
1831 .with_currency("USD");
1832
1833 let directives = vec![
1834 Directive::Open(
1835 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1836 ),
1837 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1838 Directive::Transaction(
1840 Transaction::new(date(2024, 1, 15), "Buy")
1841 .with_posting(
1842 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1843 .with_cost(cost_spec.clone()),
1844 )
1845 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1846 ),
1847 Directive::Transaction(
1849 Transaction::new(date(2024, 6, 1), "Sell too many")
1850 .with_posting(
1851 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1852 .with_cost(cost_spec),
1853 )
1854 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1855 ),
1856 ];
1857
1858 let errors = validate(&directives);
1859 assert!(
1860 errors
1861 .iter()
1862 .any(|e| e.code == ErrorCode::InsufficientUnits),
1863 "Should error for insufficient units: {errors:?}"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_validate_no_matching_lot() {
1869 use rustledger_core::CostSpec;
1870
1871 let directives = vec![
1872 Directive::Open(
1873 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1874 ),
1875 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1876 Directive::Transaction(
1878 Transaction::new(date(2024, 1, 15), "Buy")
1879 .with_posting(
1880 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1881 CostSpec::empty()
1882 .with_number_per(dec!(150))
1883 .with_currency("USD"),
1884 ),
1885 )
1886 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1887 ),
1888 Directive::Transaction(
1890 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1891 .with_posting(
1892 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1893 CostSpec::empty()
1894 .with_number_per(dec!(160))
1895 .with_currency("USD"),
1896 ),
1897 )
1898 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1899 ),
1900 ];
1901
1902 let errors = validate(&directives);
1903 assert!(
1904 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1905 "Should error for no matching lot: {errors:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_validate_multiple_lot_match_uses_fifo() {
1911 use rustledger_core::CostSpec;
1914
1915 let cost_spec = CostSpec::empty()
1916 .with_number_per(dec!(150))
1917 .with_currency("USD");
1918
1919 let directives = vec![
1920 Directive::Open(
1921 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1922 ),
1923 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1924 Directive::Transaction(
1926 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1927 .with_posting(
1928 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1929 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1930 )
1931 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1932 ),
1933 Directive::Transaction(
1935 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1936 .with_posting(
1937 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1938 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1939 )
1940 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1941 ),
1942 Directive::Transaction(
1944 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1945 .with_posting(
1946 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1947 .with_cost(cost_spec),
1948 )
1949 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1950 ),
1951 ];
1952
1953 let errors = validate(&directives);
1954 let booking_errors: Vec<_> = errors
1956 .iter()
1957 .filter(|e| {
1958 matches!(
1959 e.code,
1960 ErrorCode::InsufficientUnits
1961 | ErrorCode::NoMatchingLot
1962 | ErrorCode::AmbiguousLotMatch
1963 )
1964 })
1965 .collect();
1966 assert!(
1967 booking_errors.is_empty(),
1968 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_validate_successful_booking() {
1974 use rustledger_core::CostSpec;
1975
1976 let cost_spec = CostSpec::empty()
1977 .with_number_per(dec!(150))
1978 .with_currency("USD");
1979
1980 let directives = vec![
1981 Directive::Open(
1982 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1983 ),
1984 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1985 Directive::Transaction(
1987 Transaction::new(date(2024, 1, 15), "Buy")
1988 .with_posting(
1989 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1990 .with_cost(cost_spec.clone()),
1991 )
1992 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1993 ),
1994 Directive::Transaction(
1996 Transaction::new(date(2024, 6, 1), "Sell")
1997 .with_posting(
1998 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1999 .with_cost(cost_spec),
2000 )
2001 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
2002 ),
2003 ];
2004
2005 let errors = validate(&directives);
2006 let booking_errors: Vec<_> = errors
2008 .iter()
2009 .filter(|e| {
2010 matches!(
2011 e.code,
2012 ErrorCode::InsufficientUnits
2013 | ErrorCode::NoMatchingLot
2014 | ErrorCode::AmbiguousLotMatch
2015 )
2016 })
2017 .collect();
2018 assert!(
2019 booking_errors.is_empty(),
2020 "Should have no booking errors: {booking_errors:?}"
2021 );
2022 }
2023
2024 #[test]
2025 fn test_validate_account_already_open() {
2026 let directives = vec![
2027 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2028 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2030
2031 let errors = validate(&directives);
2032 assert!(
2033 errors
2034 .iter()
2035 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2036 "Should error for duplicate open: {errors:?}"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_validate_account_close_not_empty() {
2042 let directives = vec![
2043 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2044 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2045 Directive::Transaction(
2046 Transaction::new(date(2024, 1, 15), "Deposit")
2047 .with_posting(Posting::new(
2048 "Assets:Bank",
2049 Amount::new(dec!(100.00), "USD"),
2050 ))
2051 .with_posting(Posting::new(
2052 "Income:Salary",
2053 Amount::new(dec!(-100.00), "USD"),
2054 )),
2055 ),
2056 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2058
2059 let errors = validate(&directives);
2060 assert!(
2061 errors
2062 .iter()
2063 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2064 "Should warn for closing account with balance: {errors:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_validate_no_postings_allowed() {
2070 let directives = vec![
2073 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2074 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2075 ];
2076
2077 let errors = validate(&directives);
2078 assert!(
2079 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2080 "Should NOT error for transaction with no postings: {errors:?}"
2081 );
2082 }
2083
2084 #[test]
2085 fn test_validate_single_posting() {
2086 let directives = vec![
2087 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2088 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
2089 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2090 )),
2091 ];
2092
2093 let errors = validate(&directives);
2094 assert!(
2095 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2096 "Should warn for transaction with single posting: {errors:?}"
2097 );
2098 assert!(ErrorCode::SinglePosting.is_warning());
2100 }
2101
2102 #[test]
2103 fn test_validate_pad_without_balance() {
2104 let directives = vec![
2105 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2106 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2107 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2108 ];
2110
2111 let errors = validate(&directives);
2112 assert!(
2113 errors
2114 .iter()
2115 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2116 "Should error for pad without subsequent balance: {errors:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_validate_multiple_pads_for_balance() {
2122 let directives = vec![
2123 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2124 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2125 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2126 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2128 date(2024, 1, 3),
2129 "Assets:Bank",
2130 Amount::new(dec!(1000.00), "USD"),
2131 )),
2132 ];
2133
2134 let errors = validate(&directives);
2135 assert!(
2136 errors
2137 .iter()
2138 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2139 "Should error for multiple pads before balance: {errors:?}"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_error_severity() {
2145 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2147 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2148 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2149
2150 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2152 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2153 assert_eq!(
2154 ErrorCode::AccountCloseNotEmpty.severity(),
2155 Severity::Warning
2156 );
2157
2158 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2160 }
2161
2162 #[test]
2163 fn test_validate_invalid_account_name() {
2164 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2166
2167 let errors = validate(&directives);
2168 assert!(
2169 errors
2170 .iter()
2171 .any(|e| e.code == ErrorCode::InvalidAccountName),
2172 "Should error for invalid account root: {errors:?}"
2173 );
2174 }
2175
2176 #[test]
2177 fn test_validate_account_lowercase_component() {
2178 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2180
2181 let errors = validate(&directives);
2182 assert!(
2183 errors
2184 .iter()
2185 .any(|e| e.code == ErrorCode::InvalidAccountName),
2186 "Should error for lowercase component: {errors:?}"
2187 );
2188 }
2189
2190 #[test]
2191 fn test_validate_valid_account_names() {
2192 let valid_names = [
2194 "Assets:Bank",
2195 "Assets:Bank:Checking",
2196 "Liabilities:CreditCard",
2197 "Equity:Opening-Balances",
2198 "Income:Salary2024",
2199 "Expenses:Food:Restaurant",
2200 "Assets:401k", "Assets:CORP✨", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Test💰Account", "Assets:€uro", ];
2208
2209 for name in valid_names {
2210 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2211
2212 let errors = validate(&directives);
2213 let name_errors: Vec<_> = errors
2214 .iter()
2215 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2216 .collect();
2217 assert!(
2218 name_errors.is_empty(),
2219 "Should accept valid account name '{name}': {name_errors:?}"
2220 );
2221 }
2222 }
2223}