1use datasynth_core::models::JournalEntry;
4use rust_decimal::Decimal;
5
6#[macro_export]
8macro_rules! assert_balanced {
9 ($entry:expr) => {{
10 let entry = &$entry;
11 let total_debits: rust_decimal::Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
12 let total_credits: rust_decimal::Decimal =
13 entry.lines.iter().map(|l| l.credit_amount).sum();
14 assert_eq!(
15 total_debits, total_credits,
16 "Journal entry is not balanced: debits={}, credits={}",
17 total_debits, total_credits
18 );
19 }};
20}
21
22#[macro_export]
24macro_rules! assert_all_balanced {
25 ($entries:expr) => {{
26 for (i, entry) in $entries.iter().enumerate() {
27 let total_debits: rust_decimal::Decimal =
28 entry.lines.iter().map(|l| l.debit_amount).sum();
29 let total_credits: rust_decimal::Decimal =
30 entry.lines.iter().map(|l| l.credit_amount).sum();
31 assert_eq!(
32 total_debits, total_credits,
33 "Journal entry {} is not balanced: debits={}, credits={}",
34 i, total_debits, total_credits
35 );
36 }
37 }};
38}
39
40#[macro_export]
43macro_rules! assert_benford_compliant {
44 ($amounts:expr, $tolerance:expr) => {{
45 let amounts = &$amounts;
46 let expected = [0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046];
47 let mut counts = [0u64; 9];
48 let mut total = 0u64;
49
50 for amount in amounts.iter() {
51 if *amount > rust_decimal::Decimal::ZERO {
52 let first_digit = amount
53 .to_string()
54 .chars()
55 .find(|c| c.is_ascii_digit() && *c != '0')
56 .map(|c| c.to_digit(10).unwrap() as usize);
57
58 if let Some(d) = first_digit {
59 if d >= 1 && d <= 9 {
60 counts[d - 1] += 1;
61 total += 1;
62 }
63 }
64 }
65 }
66
67 if total > 0 {
68 for (i, (count, exp)) in counts.iter().zip(expected.iter()).enumerate() {
69 let observed = *count as f64 / total as f64;
70 let diff = (observed - exp).abs();
71 assert!(
72 diff < $tolerance,
73 "Benford's Law violation for digit {}: observed={:.4}, expected={:.4}, diff={:.4}",
74 i + 1,
75 observed,
76 exp,
77 diff
78 );
79 }
80 }
81 }};
82}
83
84pub fn is_balanced(entry: &JournalEntry) -> bool {
86 let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
87 let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
88 total_debits == total_credits
89}
90
91pub fn calculate_imbalance(entry: &JournalEntry) -> Decimal {
93 let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
94 let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
95 total_debits - total_credits
96}
97
98pub fn check_benford_distribution(amounts: &[Decimal]) -> (f64, bool) {
101 let expected = [
102 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
103 ];
104 let mut counts = [0u64; 9];
105 let mut total = 0u64;
106
107 for amount in amounts.iter() {
108 if *amount > Decimal::ZERO {
109 let first_digit = amount
110 .to_string()
111 .chars()
112 .find(|c| c.is_ascii_digit() && *c != '0')
113 .map(|c| c.to_digit(10).unwrap() as usize);
114
115 if let Some(d) = first_digit {
116 if (1..=9).contains(&d) {
117 counts[d - 1] += 1;
118 total += 1;
119 }
120 }
121 }
122 }
123
124 if total == 0 {
125 return (0.0, true);
126 }
127
128 let mut chi_squared = 0.0;
130 for (count, exp) in counts.iter().zip(expected.iter()) {
131 let expected_count = exp * total as f64;
132 if expected_count > 0.0 {
133 let diff = *count as f64 - expected_count;
134 chi_squared += diff * diff / expected_count;
135 }
136 }
137
138 let passes = chi_squared < 20.090;
141
142 (chi_squared, passes)
143}
144
145pub fn check_accounting_equation(
147 total_assets: Decimal,
148 total_liabilities: Decimal,
149 total_equity: Decimal,
150) -> bool {
151 total_assets == total_liabilities + total_equity
152}
153
154pub fn check_trial_balance(debit_balances: &[Decimal], credit_balances: &[Decimal]) -> bool {
156 let total_debits: Decimal = debit_balances.iter().copied().sum();
157 let total_credits: Decimal = credit_balances.iter().copied().sum();
158 total_debits == total_credits
159}
160
161#[macro_export]
168macro_rules! assert_benford_passes {
169 ($amounts:expr, $threshold:expr) => {{
170 let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
171 assert!(
172 passes || chi_squared < $threshold,
173 "Benford's Law test failed: chi-squared={:.4}, threshold={}",
174 chi_squared,
175 $threshold
176 );
177 }};
178 ($amounts:expr) => {{
179 let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
180 assert!(
181 passes,
182 "Benford's Law test failed: chi-squared={:.4}, p < 0.01 threshold=20.090",
183 chi_squared
184 );
185 }};
186}
187
188#[derive(Debug, Clone)]
190pub struct BalanceSnapshot {
191 pub assets: Decimal,
193 pub liabilities: Decimal,
195 pub equity: Decimal,
197 pub period: String,
199}
200
201impl BalanceSnapshot {
202 pub fn new(assets: Decimal, liabilities: Decimal, equity: Decimal, period: &str) -> Self {
204 Self {
205 assets,
206 liabilities,
207 equity,
208 period: period.into(),
209 }
210 }
211
212 pub fn is_coherent(&self, tolerance: Decimal) -> bool {
214 let diff = self.assets - (self.liabilities + self.equity);
215 diff.abs() <= tolerance
216 }
217}
218
219#[macro_export]
222macro_rules! assert_balance_coherent {
223 ($snapshots:expr, $tolerance:expr) => {{
224 let tolerance =
225 rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
226 for snapshot in $snapshots.iter() {
227 assert!(
228 snapshot.is_coherent(tolerance),
229 "Balance not coherent for period {}: assets={}, liabilities={}, equity={}, diff={}",
230 snapshot.period,
231 snapshot.assets,
232 snapshot.liabilities,
233 snapshot.equity,
234 snapshot.assets - (snapshot.liabilities + snapshot.equity)
235 );
236 }
237 }};
238}
239
240#[derive(Debug, Clone)]
242pub struct SubledgerReconciliation {
243 pub subledger: String,
245 pub subledger_total: Decimal,
247 pub gl_balance: Decimal,
249 pub period: String,
251}
252
253impl SubledgerReconciliation {
254 pub fn new(
256 subledger: &str,
257 subledger_total: Decimal,
258 gl_balance: Decimal,
259 period: &str,
260 ) -> Self {
261 Self {
262 subledger: subledger.into(),
263 subledger_total,
264 gl_balance,
265 period: period.into(),
266 }
267 }
268
269 pub fn is_reconciled(&self, tolerance: Decimal) -> bool {
271 let diff = (self.subledger_total - self.gl_balance).abs();
272 diff <= tolerance
273 }
274
275 pub fn difference(&self) -> Decimal {
277 self.subledger_total - self.gl_balance
278 }
279}
280
281#[macro_export]
283macro_rules! assert_subledger_reconciled {
284 ($reconciliations:expr, $tolerance:expr) => {{
285 let tolerance =
286 rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
287 for recon in $reconciliations.iter() {
288 assert!(
289 recon.is_reconciled(tolerance),
290 "Subledger {} not reconciled for period {}: subledger={}, gl={}, diff={}",
291 recon.subledger,
292 recon.period,
293 recon.subledger_total,
294 recon.gl_balance,
295 recon.difference()
296 );
297 }
298 }};
299}
300
301#[derive(Debug, Clone)]
303pub struct DocumentChainResult {
304 pub chain_id: String,
306 pub is_complete: bool,
308 pub missing_steps: Vec<String>,
310 pub expected_steps: usize,
312 pub actual_steps: usize,
314}
315
316impl DocumentChainResult {
317 pub fn new(chain_id: &str, expected_steps: usize, actual_steps: usize) -> Self {
319 Self {
320 chain_id: chain_id.into(),
321 is_complete: actual_steps >= expected_steps,
322 missing_steps: Vec::new(),
323 expected_steps,
324 actual_steps,
325 }
326 }
327
328 pub fn complete(chain_id: &str, steps: usize) -> Self {
330 Self::new(chain_id, steps, steps)
331 }
332
333 pub fn incomplete(
335 chain_id: &str,
336 expected: usize,
337 actual: usize,
338 missing: Vec<String>,
339 ) -> Self {
340 Self {
341 chain_id: chain_id.into(),
342 is_complete: false,
343 missing_steps: missing,
344 expected_steps: expected,
345 actual_steps: actual,
346 }
347 }
348
349 pub fn completion_rate(&self) -> f64 {
351 if self.expected_steps == 0 {
352 1.0
353 } else {
354 self.actual_steps as f64 / self.expected_steps as f64
355 }
356 }
357}
358
359pub fn check_document_chain_completeness(chains: &[DocumentChainResult]) -> (f64, usize, usize) {
361 if chains.is_empty() {
362 return (1.0, 0, 0);
363 }
364
365 let complete_count = chains.iter().filter(|c| c.is_complete).count();
366 let total_count = chains.len();
367 let rate = complete_count as f64 / total_count as f64;
368
369 (rate, complete_count, total_count)
370}
371
372#[macro_export]
374macro_rules! assert_document_chain_complete {
375 ($chains:expr, $threshold:expr) => {{
376 let (rate, complete, total) =
377 $crate::assertions::check_document_chain_completeness(&$chains);
378 assert!(
379 rate >= $threshold,
380 "Document chain completeness {:.2}% below threshold {:.2}%: {}/{} complete",
381 rate * 100.0,
382 $threshold * 100.0,
383 complete,
384 total
385 );
386
387 for chain in $chains.iter().filter(|c| !c.is_complete) {
389 eprintln!(
390 "Incomplete chain {}: {}/{} steps, missing: {:?}",
391 chain.chain_id, chain.actual_steps, chain.expected_steps, chain.missing_steps
392 );
393 }
394 }};
395}
396
397#[derive(Debug, Clone)]
399pub struct FidelityResult {
400 pub overall_score: f64,
402 pub statistical_score: f64,
404 pub schema_score: f64,
406 pub correlation_score: f64,
408 pub passes: bool,
410 pub threshold: f64,
412}
413
414impl FidelityResult {
415 pub fn new(statistical: f64, schema: f64, correlation: f64, threshold: f64) -> Self {
417 let overall = statistical * 0.50 + schema * 0.25 + correlation * 0.25;
419
420 Self {
421 overall_score: overall,
422 statistical_score: statistical,
423 schema_score: schema,
424 correlation_score: correlation,
425 passes: overall >= threshold,
426 threshold,
427 }
428 }
429
430 pub fn perfect(threshold: f64) -> Self {
432 Self::new(1.0, 1.0, 1.0, threshold)
433 }
434}
435
436pub fn check_fidelity(
438 statistical_score: f64,
439 schema_score: f64,
440 correlation_score: f64,
441 threshold: f64,
442) -> FidelityResult {
443 FidelityResult::new(
444 statistical_score,
445 schema_score,
446 correlation_score,
447 threshold,
448 )
449}
450
451#[macro_export]
453macro_rules! assert_fidelity_passes {
454 ($result:expr) => {{
455 assert!(
456 $result.passes,
457 "Fidelity check failed: overall={:.4} < threshold={:.4}\n \
458 statistical={:.4}, schema={:.4}, correlation={:.4}",
459 $result.overall_score,
460 $result.threshold,
461 $result.statistical_score,
462 $result.schema_score,
463 $result.correlation_score
464 );
465 }};
466 ($statistical:expr, $schema:expr, $correlation:expr, $threshold:expr) => {{
467 let result =
468 $crate::assertions::check_fidelity($statistical, $schema, $correlation, $threshold);
469 assert!(
470 result.passes,
471 "Fidelity check failed: overall={:.4} < threshold={:.4}\n \
472 statistical={:.4}, schema={:.4}, correlation={:.4}",
473 result.overall_score,
474 result.threshold,
475 result.statistical_score,
476 result.schema_score,
477 result.correlation_score
478 );
479 }};
480}
481
482pub fn benford_mad(amounts: &[Decimal]) -> f64 {
484 let expected = [
485 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
486 ];
487 let mut counts = [0u64; 9];
488 let mut total = 0u64;
489
490 for amount in amounts.iter() {
491 if *amount > Decimal::ZERO {
492 let first_digit = amount
493 .to_string()
494 .chars()
495 .find(|c| c.is_ascii_digit() && *c != '0')
496 .and_then(|c| c.to_digit(10))
497 .map(|d| d as usize);
498
499 if let Some(d) = first_digit {
500 if (1..=9).contains(&d) {
501 counts[d - 1] += 1;
502 total += 1;
503 }
504 }
505 }
506 }
507
508 if total == 0 {
509 return 0.0;
510 }
511
512 let mut mad = 0.0;
514 for (count, exp) in counts.iter().zip(expected.iter()) {
515 let observed = *count as f64 / total as f64;
516 mad += (observed - exp).abs();
517 }
518
519 mad / 9.0
520}
521
522#[cfg(test)]
523#[allow(clippy::unwrap_used)]
524mod tests {
525 use super::*;
526 use crate::fixtures::*;
527
528 #[test]
529 fn test_is_balanced() {
530 let entry = balanced_journal_entry(Decimal::new(10000, 2));
531 assert!(is_balanced(&entry));
532 }
533
534 #[test]
535 fn test_is_not_balanced() {
536 let entry = unbalanced_journal_entry();
537 assert!(!is_balanced(&entry));
538 }
539
540 #[test]
541 fn test_calculate_imbalance_balanced() {
542 let entry = balanced_journal_entry(Decimal::new(10000, 2));
543 assert_eq!(calculate_imbalance(&entry), Decimal::ZERO);
544 }
545
546 #[test]
547 fn test_calculate_imbalance_unbalanced() {
548 let entry = unbalanced_journal_entry();
549 let imbalance = calculate_imbalance(&entry);
550 assert_ne!(imbalance, Decimal::ZERO);
551 }
552
553 #[test]
554 fn test_check_accounting_equation() {
555 assert!(check_accounting_equation(
557 Decimal::new(1000, 0),
558 Decimal::new(600, 0),
559 Decimal::new(400, 0)
560 ));
561
562 assert!(!check_accounting_equation(
564 Decimal::new(1000, 0),
565 Decimal::new(600, 0),
566 Decimal::new(300, 0)
567 ));
568 }
569
570 #[test]
571 fn test_check_trial_balance() {
572 let debits = vec![Decimal::new(1000, 0), Decimal::new(500, 0)];
573 let credits = vec![Decimal::new(1500, 0)];
574 assert!(check_trial_balance(&debits, &credits));
575
576 let unbalanced_credits = vec![Decimal::new(1000, 0)];
577 assert!(!check_trial_balance(&debits, &unbalanced_credits));
578 }
579
580 #[test]
581 fn test_benford_distribution_perfect() {
582 let mut amounts = Vec::new();
584 let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46]; for (digit, count) in expected_counts.iter().enumerate() {
587 let base = Decimal::new((digit + 1) as i64, 0);
588 for _ in 0..*count {
589 amounts.push(base);
590 }
591 }
592
593 let (chi_squared, passes) = check_benford_distribution(&amounts);
594 assert!(passes, "Chi-squared: {}", chi_squared);
595 }
596
597 #[test]
598 fn test_assert_balanced_macro() {
599 let entry = balanced_journal_entry(Decimal::new(10000, 2));
600 assert_balanced!(entry); }
602
603 #[test]
604 fn test_assert_all_balanced_macro() {
605 let entries = [
606 balanced_journal_entry(Decimal::new(10000, 2)),
607 balanced_journal_entry(Decimal::new(20000, 2)),
608 balanced_journal_entry(Decimal::new(30000, 2)),
609 ];
610 assert_all_balanced!(entries); }
612
613 #[test]
618 fn test_balance_snapshot_coherent() {
619 let snapshot = BalanceSnapshot::new(
620 Decimal::new(1000, 0),
621 Decimal::new(600, 0),
622 Decimal::new(400, 0),
623 "2025-01",
624 );
625 assert!(snapshot.is_coherent(Decimal::ZERO));
626 }
627
628 #[test]
629 fn test_balance_snapshot_incoherent() {
630 let snapshot = BalanceSnapshot::new(
631 Decimal::new(1000, 0),
632 Decimal::new(600, 0),
633 Decimal::new(300, 0), "2025-01",
635 );
636 assert!(!snapshot.is_coherent(Decimal::ZERO));
637 }
638
639 #[test]
640 fn test_balance_snapshot_with_tolerance() {
641 let snapshot = BalanceSnapshot::new(
642 Decimal::new(1001, 0), Decimal::new(600, 0),
644 Decimal::new(400, 0),
645 "2025-01",
646 );
647 assert!(!snapshot.is_coherent(Decimal::ZERO));
648 assert!(snapshot.is_coherent(Decimal::new(1, 0)));
649 assert!(snapshot.is_coherent(Decimal::new(5, 0)));
650 }
651
652 #[test]
653 fn test_assert_balance_coherent_macro() {
654 let snapshots = [
655 BalanceSnapshot::new(
656 Decimal::new(1000, 0),
657 Decimal::new(600, 0),
658 Decimal::new(400, 0),
659 "2025-01",
660 ),
661 BalanceSnapshot::new(
662 Decimal::new(1200, 0),
663 Decimal::new(700, 0),
664 Decimal::new(500, 0),
665 "2025-02",
666 ),
667 ];
668 assert_balance_coherent!(snapshots, 0.0);
669 }
670
671 #[test]
672 fn test_subledger_reconciliation() {
673 let recon = SubledgerReconciliation::new(
674 "AR",
675 Decimal::new(50000, 0),
676 Decimal::new(50000, 0),
677 "2025-01",
678 );
679 assert!(recon.is_reconciled(Decimal::ZERO));
680 assert_eq!(recon.difference(), Decimal::ZERO);
681 }
682
683 #[test]
684 fn test_subledger_reconciliation_with_tolerance() {
685 let recon = SubledgerReconciliation::new(
686 "AP",
687 Decimal::new(50010, 0), Decimal::new(50000, 0),
689 "2025-01",
690 );
691 assert!(!recon.is_reconciled(Decimal::new(5, 0)));
692 assert!(recon.is_reconciled(Decimal::new(10, 0)));
693 assert!(recon.is_reconciled(Decimal::new(100, 0)));
694 }
695
696 #[test]
697 fn test_assert_subledger_reconciled_macro() {
698 let reconciliations = [
699 SubledgerReconciliation::new(
700 "AR",
701 Decimal::new(50000, 0),
702 Decimal::new(50000, 0),
703 "2025-01",
704 ),
705 SubledgerReconciliation::new(
706 "AP",
707 Decimal::new(30000, 0),
708 Decimal::new(30000, 0),
709 "2025-01",
710 ),
711 ];
712 assert_subledger_reconciled!(reconciliations, 0.0);
713 }
714
715 #[test]
716 fn test_document_chain_complete() {
717 let chain = DocumentChainResult::complete("PO-001", 5);
718 assert!(chain.is_complete);
719 assert_eq!(chain.completion_rate(), 1.0);
720 }
721
722 #[test]
723 fn test_document_chain_incomplete() {
724 let chain =
725 DocumentChainResult::incomplete("PO-002", 5, 3, vec!["Payment".into(), "Close".into()]);
726 assert!(!chain.is_complete);
727 assert_eq!(chain.completion_rate(), 0.6);
728 }
729
730 #[test]
731 fn test_check_document_chain_completeness() {
732 let chains = vec![
733 DocumentChainResult::complete("PO-001", 5),
734 DocumentChainResult::complete("PO-002", 5),
735 DocumentChainResult::incomplete("PO-003", 5, 3, vec!["Payment".into()]),
736 ];
737
738 let (rate, complete, total) = check_document_chain_completeness(&chains);
739 assert_eq!(complete, 2);
740 assert_eq!(total, 3);
741 assert!((rate - 0.6667).abs() < 0.01);
742 }
743
744 #[test]
745 fn test_assert_document_chain_complete_macro() {
746 let chains = vec![
747 DocumentChainResult::complete("PO-001", 5),
748 DocumentChainResult::complete("PO-002", 5),
749 DocumentChainResult::complete("PO-003", 5),
750 ];
751 assert_document_chain_complete!(chains, 0.9);
752 }
753
754 #[test]
755 fn test_fidelity_result() {
756 let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
757
758 assert!((result.overall_score - 0.95).abs() < 0.001);
760 assert!(result.passes);
761 }
762
763 #[test]
764 fn test_fidelity_result_fails() {
765 let result = FidelityResult::new(0.50, 0.50, 0.50, 0.80);
766
767 assert!((result.overall_score - 0.50).abs() < 0.001);
769 assert!(!result.passes);
770 }
771
772 #[test]
773 fn test_fidelity_perfect() {
774 let result = FidelityResult::perfect(0.90);
775 assert_eq!(result.overall_score, 1.0);
776 assert!(result.passes);
777 }
778
779 #[test]
780 fn test_assert_fidelity_passes_macro() {
781 let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
782 assert_fidelity_passes!(result);
783 }
784
785 #[test]
786 fn test_assert_fidelity_passes_inline() {
787 assert_fidelity_passes!(0.95, 1.0, 0.90, 0.80);
788 }
789
790 #[test]
791 fn test_benford_mad() {
792 let mut amounts = Vec::new();
794 let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46];
795
796 for (digit, count) in expected_counts.iter().enumerate() {
797 let base = Decimal::new((digit + 1) as i64, 0);
798 for _ in 0..*count {
799 amounts.push(base);
800 }
801 }
802
803 let mad = benford_mad(&amounts);
804 assert!(
805 mad < 0.01,
806 "Perfect Benford distribution should have very low MAD: {}",
807 mad
808 );
809 }
810
811 #[test]
812 fn test_benford_mad_uniform() {
813 let mut amounts = Vec::new();
815 for digit in 1..=9 {
816 for _ in 0..100 {
817 amounts.push(Decimal::new(digit, 0));
818 }
819 }
820
821 let mad = benford_mad(&amounts);
822 assert!(
823 mad > 0.02,
824 "Uniform distribution should have high MAD: {}",
825 mad
826 );
827 }
828}