1use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9
10use super::account_balance::BalanceSnapshot;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RelationshipType {
16 DaysSalesOutstanding,
18 DaysPayableOutstanding,
20 DaysInventoryOutstanding,
22 CashConversionCycle,
24 GrossMargin,
26 OperatingMargin,
28 NetMargin,
30 CurrentRatio,
32 QuickRatio,
34 DebtToEquity,
36 InterestCoverage,
38 AssetTurnover,
40 ReturnOnAssets,
42 ReturnOnEquity,
44 DepreciationRate,
46 BalanceSheetEquation,
48 RetainedEarningsRollForward,
50}
51
52impl RelationshipType {
53 pub fn display_name(&self) -> &'static str {
55 match self {
56 Self::DaysSalesOutstanding => "Days Sales Outstanding",
57 Self::DaysPayableOutstanding => "Days Payable Outstanding",
58 Self::DaysInventoryOutstanding => "Days Inventory Outstanding",
59 Self::CashConversionCycle => "Cash Conversion Cycle",
60 Self::GrossMargin => "Gross Margin",
61 Self::OperatingMargin => "Operating Margin",
62 Self::NetMargin => "Net Margin",
63 Self::CurrentRatio => "Current Ratio",
64 Self::QuickRatio => "Quick Ratio",
65 Self::DebtToEquity => "Debt-to-Equity Ratio",
66 Self::InterestCoverage => "Interest Coverage Ratio",
67 Self::AssetTurnover => "Asset Turnover",
68 Self::ReturnOnAssets => "Return on Assets",
69 Self::ReturnOnEquity => "Return on Equity",
70 Self::DepreciationRate => "Depreciation Rate",
71 Self::BalanceSheetEquation => "Balance Sheet Equation",
72 Self::RetainedEarningsRollForward => "Retained Earnings Roll-forward",
73 }
74 }
75
76 pub fn is_critical(&self) -> bool {
78 matches!(
79 self,
80 Self::BalanceSheetEquation | Self::RetainedEarningsRollForward
81 )
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct BalanceRelationshipRule {
88 pub rule_id: String,
90 pub name: String,
92 pub relationship_type: RelationshipType,
94 pub target_value: Option<Decimal>,
96 pub min_value: Option<Decimal>,
98 pub max_value: Option<Decimal>,
100 pub tolerance: Decimal,
102 pub enabled: bool,
104 pub severity: RuleSeverity,
106 pub numerator_accounts: Vec<String>,
108 pub denominator_accounts: Vec<String>,
110 pub multiplier: Decimal,
112}
113
114impl BalanceRelationshipRule {
115 pub fn new_dso_rule(target_days: u32, tolerance_days: u32) -> Self {
117 Self {
118 rule_id: "DSO".to_string(),
119 name: "Days Sales Outstanding".to_string(),
120 relationship_type: RelationshipType::DaysSalesOutstanding,
121 target_value: Some(Decimal::from(target_days)),
122 min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
123 max_value: Some(Decimal::from(target_days + tolerance_days)),
124 tolerance: Decimal::from(tolerance_days),
125 enabled: true,
126 severity: RuleSeverity::Warning,
127 numerator_accounts: vec!["1200".to_string()], denominator_accounts: vec!["4100".to_string()], multiplier: dec!(365),
130 }
131 }
132
133 pub fn new_dpo_rule(target_days: u32, tolerance_days: u32) -> Self {
135 Self {
136 rule_id: "DPO".to_string(),
137 name: "Days Payable Outstanding".to_string(),
138 relationship_type: RelationshipType::DaysPayableOutstanding,
139 target_value: Some(Decimal::from(target_days)),
140 min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
141 max_value: Some(Decimal::from(target_days + tolerance_days)),
142 tolerance: Decimal::from(tolerance_days),
143 enabled: true,
144 severity: RuleSeverity::Warning,
145 numerator_accounts: vec!["2100".to_string()], denominator_accounts: vec!["5100".to_string()], multiplier: dec!(365),
148 }
149 }
150
151 pub fn new_gross_margin_rule(target_margin: Decimal, tolerance: Decimal) -> Self {
153 Self {
154 rule_id: "GROSS_MARGIN".to_string(),
155 name: "Gross Margin".to_string(),
156 relationship_type: RelationshipType::GrossMargin,
157 target_value: Some(target_margin),
158 min_value: Some(target_margin - tolerance),
159 max_value: Some(target_margin + tolerance),
160 tolerance,
161 enabled: true,
162 severity: RuleSeverity::Warning,
163 numerator_accounts: vec!["4100".to_string(), "5100".to_string()], denominator_accounts: vec!["4100".to_string()], multiplier: Decimal::ONE,
166 }
167 }
168
169 pub fn new_balance_equation_rule() -> Self {
171 Self {
172 rule_id: "BS_EQUATION".to_string(),
173 name: "Balance Sheet Equation".to_string(),
174 relationship_type: RelationshipType::BalanceSheetEquation,
175 target_value: Some(Decimal::ZERO),
176 min_value: Some(dec!(-0.01)),
177 max_value: Some(dec!(0.01)),
178 tolerance: dec!(0.01),
179 enabled: true,
180 severity: RuleSeverity::Critical,
181 numerator_accounts: Vec::new(),
182 denominator_accounts: Vec::new(),
183 multiplier: Decimal::ONE,
184 }
185 }
186
187 pub fn is_within_range(&self, value: Decimal) -> bool {
189 let within_min = self.min_value.is_none_or(|min| value >= min);
190 let within_max = self.max_value.is_none_or(|max| value <= max);
191 within_min && within_max
192 }
193
194 pub fn deviation_from_target(&self, value: Decimal) -> Option<Decimal> {
196 self.target_value.map(|target| value - target)
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum RuleSeverity {
204 Info,
206 #[default]
208 Warning,
209 Error,
211 Critical,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ValidationResult {
218 pub rule_id: String,
220 pub rule_name: String,
222 pub relationship_type: RelationshipType,
224 pub calculated_value: Decimal,
226 pub target_value: Option<Decimal>,
228 pub is_valid: bool,
230 pub deviation: Option<Decimal>,
232 pub deviation_percent: Option<Decimal>,
234 pub severity: RuleSeverity,
236 pub message: String,
238}
239
240impl ValidationResult {
241 pub fn pass(rule: &BalanceRelationshipRule, calculated_value: Decimal) -> Self {
243 let deviation = rule.deviation_from_target(calculated_value);
244 let deviation_percent = rule.target_value.and_then(|target| {
245 if target != Decimal::ZERO {
246 Some((calculated_value - target) / target * dec!(100))
247 } else {
248 None
249 }
250 });
251
252 Self {
253 rule_id: rule.rule_id.clone(),
254 rule_name: rule.name.clone(),
255 relationship_type: rule.relationship_type,
256 calculated_value,
257 target_value: rule.target_value,
258 is_valid: true,
259 deviation,
260 deviation_percent,
261 severity: RuleSeverity::Info,
262 message: format!(
263 "{} = {:.2} (within acceptable range)",
264 rule.name, calculated_value
265 ),
266 }
267 }
268
269 pub fn fail(
271 rule: &BalanceRelationshipRule,
272 calculated_value: Decimal,
273 message: String,
274 ) -> Self {
275 let deviation = rule.deviation_from_target(calculated_value);
276 let deviation_percent = rule.target_value.and_then(|target| {
277 if target != Decimal::ZERO {
278 Some((calculated_value - target) / target * dec!(100))
279 } else {
280 None
281 }
282 });
283
284 Self {
285 rule_id: rule.rule_id.clone(),
286 rule_name: rule.name.clone(),
287 relationship_type: rule.relationship_type,
288 calculated_value,
289 target_value: rule.target_value,
290 is_valid: false,
291 deviation,
292 deviation_percent,
293 severity: rule.severity,
294 message,
295 }
296 }
297}
298
299pub struct BalanceCoherenceValidator {
301 rules: Vec<BalanceRelationshipRule>,
303}
304
305impl BalanceCoherenceValidator {
306 pub fn new() -> Self {
308 Self { rules: Vec::new() }
309 }
310
311 pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
313 self.rules.push(rule);
314 }
315
316 pub fn add_standard_rules(&mut self, target_dso: u32, target_dpo: u32, target_margin: Decimal) {
318 self.rules
319 .push(BalanceRelationshipRule::new_dso_rule(target_dso, 10));
320 self.rules
321 .push(BalanceRelationshipRule::new_dpo_rule(target_dpo, 10));
322 self.rules
323 .push(BalanceRelationshipRule::new_gross_margin_rule(
324 target_margin,
325 dec!(0.05),
326 ));
327 self.rules
328 .push(BalanceRelationshipRule::new_balance_equation_rule());
329 }
330
331 pub fn validate_snapshot(&self, snapshot: &BalanceSnapshot) -> Vec<ValidationResult> {
333 let mut results = Vec::new();
334
335 for rule in &self.rules {
336 if !rule.enabled {
337 continue;
338 }
339
340 let result = self.validate_rule(rule, snapshot);
341 results.push(result);
342 }
343
344 results
345 }
346
347 fn validate_rule(
349 &self,
350 rule: &BalanceRelationshipRule,
351 snapshot: &BalanceSnapshot,
352 ) -> ValidationResult {
353 match rule.relationship_type {
354 RelationshipType::BalanceSheetEquation => {
355 let equation_diff = snapshot.balance_difference;
357 if snapshot.is_balanced {
358 ValidationResult::pass(rule, equation_diff)
359 } else {
360 ValidationResult::fail(
361 rule,
362 equation_diff,
363 format!("Balance sheet is out of balance by {:.2}", equation_diff),
364 )
365 }
366 }
367 RelationshipType::CurrentRatio => {
368 let current_assets = snapshot.total_assets; let current_liabilities = snapshot.total_liabilities;
370
371 if current_liabilities == Decimal::ZERO {
372 ValidationResult::fail(
373 rule,
374 Decimal::ZERO,
375 "No current liabilities".to_string(),
376 )
377 } else {
378 let ratio = current_assets / current_liabilities;
379 if rule.is_within_range(ratio) {
380 ValidationResult::pass(rule, ratio)
381 } else {
382 ValidationResult::fail(
383 rule,
384 ratio,
385 format!("Current ratio {:.2} is outside acceptable range", ratio),
386 )
387 }
388 }
389 }
390 RelationshipType::DebtToEquity => {
391 if snapshot.total_equity == Decimal::ZERO {
392 ValidationResult::fail(rule, Decimal::ZERO, "No equity".to_string())
393 } else {
394 let ratio = snapshot.total_liabilities / snapshot.total_equity;
395 if rule.is_within_range(ratio) {
396 ValidationResult::pass(rule, ratio)
397 } else {
398 ValidationResult::fail(
399 rule,
400 ratio,
401 format!(
402 "Debt-to-equity ratio {:.2} is outside acceptable range",
403 ratio
404 ),
405 )
406 }
407 }
408 }
409 RelationshipType::GrossMargin => {
410 if snapshot.total_revenue == Decimal::ZERO {
411 ValidationResult::pass(rule, Decimal::ZERO) } else {
413 let gross_profit = snapshot.total_revenue - snapshot.total_expenses; let margin = gross_profit / snapshot.total_revenue;
415 if rule.is_within_range(margin) {
416 ValidationResult::pass(rule, margin)
417 } else {
418 ValidationResult::fail(
419 rule,
420 margin,
421 format!(
422 "Gross margin {:.1}% is outside target range",
423 margin * dec!(100)
424 ),
425 )
426 }
427 }
428 }
429 _ => {
430 let numerator: Decimal = rule
432 .numerator_accounts
433 .iter()
434 .filter_map(|code| snapshot.get_balance(code))
435 .map(|b| b.closing_balance)
436 .sum();
437
438 let denominator: Decimal = rule
439 .denominator_accounts
440 .iter()
441 .filter_map(|code| snapshot.get_balance(code))
442 .map(|b| b.closing_balance)
443 .sum();
444
445 if denominator == Decimal::ZERO {
446 ValidationResult::fail(rule, Decimal::ZERO, "Denominator is zero".to_string())
447 } else {
448 let value = numerator / denominator * rule.multiplier;
449 if rule.is_within_range(value) {
450 ValidationResult::pass(rule, value)
451 } else {
452 ValidationResult::fail(
453 rule,
454 value,
455 format!("{} = {:.2} is outside acceptable range", rule.name, value),
456 )
457 }
458 }
459 }
460 }
461 }
462
463 pub fn summarize_results(results: &[ValidationResult]) -> ValidationSummary {
465 let total = results.len();
466 let passed = results.iter().filter(|r| r.is_valid).count();
467 let failed = total - passed;
468
469 let critical_failures = results
470 .iter()
471 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Critical)
472 .count();
473
474 let error_failures = results
475 .iter()
476 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Error)
477 .count();
478
479 let warning_failures = results
480 .iter()
481 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Warning)
482 .count();
483
484 ValidationSummary {
485 total_rules: total,
486 passed,
487 failed,
488 critical_failures,
489 error_failures,
490 warning_failures,
491 is_coherent: critical_failures == 0,
492 }
493 }
494}
495
496impl Default for BalanceCoherenceValidator {
497 fn default() -> Self {
498 Self::new()
499 }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct ValidationSummary {
505 pub total_rules: usize,
507 pub passed: usize,
509 pub failed: usize,
511 pub critical_failures: usize,
513 pub error_failures: usize,
515 pub warning_failures: usize,
517 pub is_coherent: bool,
519}
520
521#[derive(Debug, Clone, Default)]
523pub struct AccountGroups {
524 pub current_assets: Vec<String>,
526 pub non_current_assets: Vec<String>,
528 pub current_liabilities: Vec<String>,
530 pub non_current_liabilities: Vec<String>,
532 pub equity: Vec<String>,
534 pub revenue: Vec<String>,
536 pub cogs: Vec<String>,
538 pub operating_expenses: Vec<String>,
540 pub accounts_receivable: Vec<String>,
542 pub accounts_payable: Vec<String>,
544 pub inventory: Vec<String>,
546 pub fixed_assets: Vec<String>,
548 pub accumulated_depreciation: Vec<String>,
550}
551
552pub fn calculate_dso(ar_balance: Decimal, annual_revenue: Decimal) -> Option<Decimal> {
554 if annual_revenue == Decimal::ZERO {
555 None
556 } else {
557 Some(ar_balance / annual_revenue * dec!(365))
558 }
559}
560
561pub fn calculate_dpo(ap_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
563 if annual_cogs == Decimal::ZERO {
564 None
565 } else {
566 Some(ap_balance / annual_cogs * dec!(365))
567 }
568}
569
570pub fn calculate_dio(inventory_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
572 if annual_cogs == Decimal::ZERO {
573 None
574 } else {
575 Some(inventory_balance / annual_cogs * dec!(365))
576 }
577}
578
579pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
581 dso + dio - dpo
582}
583
584pub fn calculate_gross_margin(revenue: Decimal, cogs: Decimal) -> Option<Decimal> {
586 if revenue == Decimal::ZERO {
587 None
588 } else {
589 Some((revenue - cogs) / revenue)
590 }
591}
592
593pub fn calculate_operating_margin(revenue: Decimal, operating_income: Decimal) -> Option<Decimal> {
595 if revenue == Decimal::ZERO {
596 None
597 } else {
598 Some(operating_income / revenue)
599 }
600}
601
602#[cfg(test)]
603#[allow(clippy::unwrap_used)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn test_dso_calculation() {
609 let ar = dec!(123288); let revenue = dec!(1000000); let dso = calculate_dso(ar, revenue).unwrap();
613 assert!((dso - dec!(45)).abs() < dec!(1));
615 }
616
617 #[test]
618 fn test_dpo_calculation() {
619 let ap = dec!(58904); let cogs = dec!(650000); let dpo = calculate_dpo(ap, cogs).unwrap();
623 assert!((dpo - dec!(33)).abs() < dec!(2));
625 }
626
627 #[test]
628 fn test_gross_margin_calculation() {
629 let revenue = dec!(1000000);
630 let cogs = dec!(650000);
631
632 let margin = calculate_gross_margin(revenue, cogs).unwrap();
633 assert_eq!(margin, dec!(0.35));
635 }
636
637 #[test]
638 fn test_ccc_calculation() {
639 let dso = dec!(45);
640 let dio = dec!(60);
641 let dpo = dec!(30);
642
643 let ccc = calculate_ccc(dso, dio, dpo);
644 assert_eq!(ccc, dec!(75));
646 }
647
648 #[test]
649 fn test_dso_rule() {
650 let rule = BalanceRelationshipRule::new_dso_rule(45, 10);
651
652 assert!(rule.is_within_range(dec!(45)));
653 assert!(rule.is_within_range(dec!(35)));
654 assert!(rule.is_within_range(dec!(55)));
655 assert!(!rule.is_within_range(dec!(30)));
656 assert!(!rule.is_within_range(dec!(60)));
657 }
658
659 #[test]
660 fn test_gross_margin_rule() {
661 let rule = BalanceRelationshipRule::new_gross_margin_rule(dec!(0.35), dec!(0.05));
662
663 assert!(rule.is_within_range(dec!(0.35)));
664 assert!(rule.is_within_range(dec!(0.30)));
665 assert!(rule.is_within_range(dec!(0.40)));
666 assert!(!rule.is_within_range(dec!(0.25)));
667 assert!(!rule.is_within_range(dec!(0.45)));
668 }
669
670 #[test]
671 fn test_validation_summary() {
672 let rule1 = BalanceRelationshipRule::new_balance_equation_rule();
673 let rule2 = BalanceRelationshipRule::new_dso_rule(45, 10);
674
675 let results = vec![
676 ValidationResult::pass(&rule1, Decimal::ZERO),
677 ValidationResult::fail(&rule2, dec!(60), "DSO too high".to_string()),
678 ];
679
680 let summary = BalanceCoherenceValidator::summarize_results(&results);
681
682 assert_eq!(summary.total_rules, 2);
683 assert_eq!(summary.passed, 1);
684 assert_eq!(summary.failed, 1);
685 assert!(summary.is_coherent); }
687}