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.map_or(true, |min| value >= min);
190 let within_max = self.max_value.map_or(true, |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 #[allow(dead_code)]
305 account_groups: AccountGroups,
306}
307
308impl BalanceCoherenceValidator {
309 pub fn new() -> Self {
311 Self {
312 rules: Vec::new(),
313 account_groups: AccountGroups::default(),
314 }
315 }
316
317 pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
319 self.rules.push(rule);
320 }
321
322 pub fn add_standard_rules(&mut self, target_dso: u32, target_dpo: u32, target_margin: Decimal) {
324 self.rules
325 .push(BalanceRelationshipRule::new_dso_rule(target_dso, 10));
326 self.rules
327 .push(BalanceRelationshipRule::new_dpo_rule(target_dpo, 10));
328 self.rules
329 .push(BalanceRelationshipRule::new_gross_margin_rule(
330 target_margin,
331 dec!(0.05),
332 ));
333 self.rules
334 .push(BalanceRelationshipRule::new_balance_equation_rule());
335 }
336
337 pub fn validate_snapshot(&self, snapshot: &BalanceSnapshot) -> Vec<ValidationResult> {
339 let mut results = Vec::new();
340
341 for rule in &self.rules {
342 if !rule.enabled {
343 continue;
344 }
345
346 let result = self.validate_rule(rule, snapshot);
347 results.push(result);
348 }
349
350 results
351 }
352
353 fn validate_rule(
355 &self,
356 rule: &BalanceRelationshipRule,
357 snapshot: &BalanceSnapshot,
358 ) -> ValidationResult {
359 match rule.relationship_type {
360 RelationshipType::BalanceSheetEquation => {
361 let equation_diff = snapshot.balance_difference;
363 if snapshot.is_balanced {
364 ValidationResult::pass(rule, equation_diff)
365 } else {
366 ValidationResult::fail(
367 rule,
368 equation_diff,
369 format!("Balance sheet is out of balance by {:.2}", equation_diff),
370 )
371 }
372 }
373 RelationshipType::CurrentRatio => {
374 let current_assets = snapshot.total_assets; let current_liabilities = snapshot.total_liabilities;
376
377 if current_liabilities == Decimal::ZERO {
378 ValidationResult::fail(
379 rule,
380 Decimal::ZERO,
381 "No current liabilities".to_string(),
382 )
383 } else {
384 let ratio = current_assets / current_liabilities;
385 if rule.is_within_range(ratio) {
386 ValidationResult::pass(rule, ratio)
387 } else {
388 ValidationResult::fail(
389 rule,
390 ratio,
391 format!("Current ratio {:.2} is outside acceptable range", ratio),
392 )
393 }
394 }
395 }
396 RelationshipType::DebtToEquity => {
397 if snapshot.total_equity == Decimal::ZERO {
398 ValidationResult::fail(rule, Decimal::ZERO, "No equity".to_string())
399 } else {
400 let ratio = snapshot.total_liabilities / snapshot.total_equity;
401 if rule.is_within_range(ratio) {
402 ValidationResult::pass(rule, ratio)
403 } else {
404 ValidationResult::fail(
405 rule,
406 ratio,
407 format!(
408 "Debt-to-equity ratio {:.2} is outside acceptable range",
409 ratio
410 ),
411 )
412 }
413 }
414 }
415 RelationshipType::GrossMargin => {
416 if snapshot.total_revenue == Decimal::ZERO {
417 ValidationResult::pass(rule, Decimal::ZERO) } else {
419 let gross_profit = snapshot.total_revenue - snapshot.total_expenses; let margin = gross_profit / snapshot.total_revenue;
421 if rule.is_within_range(margin) {
422 ValidationResult::pass(rule, margin)
423 } else {
424 ValidationResult::fail(
425 rule,
426 margin,
427 format!(
428 "Gross margin {:.1}% is outside target range",
429 margin * dec!(100)
430 ),
431 )
432 }
433 }
434 }
435 _ => {
436 let numerator: Decimal = rule
438 .numerator_accounts
439 .iter()
440 .filter_map(|code| snapshot.get_balance(code))
441 .map(|b| b.closing_balance)
442 .sum();
443
444 let denominator: Decimal = rule
445 .denominator_accounts
446 .iter()
447 .filter_map(|code| snapshot.get_balance(code))
448 .map(|b| b.closing_balance)
449 .sum();
450
451 if denominator == Decimal::ZERO {
452 ValidationResult::fail(rule, Decimal::ZERO, "Denominator is zero".to_string())
453 } else {
454 let value = numerator / denominator * rule.multiplier;
455 if rule.is_within_range(value) {
456 ValidationResult::pass(rule, value)
457 } else {
458 ValidationResult::fail(
459 rule,
460 value,
461 format!("{} = {:.2} is outside acceptable range", rule.name, value),
462 )
463 }
464 }
465 }
466 }
467 }
468
469 pub fn summarize_results(results: &[ValidationResult]) -> ValidationSummary {
471 let total = results.len();
472 let passed = results.iter().filter(|r| r.is_valid).count();
473 let failed = total - passed;
474
475 let critical_failures = results
476 .iter()
477 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Critical)
478 .count();
479
480 let error_failures = results
481 .iter()
482 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Error)
483 .count();
484
485 let warning_failures = results
486 .iter()
487 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Warning)
488 .count();
489
490 ValidationSummary {
491 total_rules: total,
492 passed,
493 failed,
494 critical_failures,
495 error_failures,
496 warning_failures,
497 is_coherent: critical_failures == 0,
498 }
499 }
500}
501
502impl Default for BalanceCoherenceValidator {
503 fn default() -> Self {
504 Self::new()
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct ValidationSummary {
511 pub total_rules: usize,
513 pub passed: usize,
515 pub failed: usize,
517 pub critical_failures: usize,
519 pub error_failures: usize,
521 pub warning_failures: usize,
523 pub is_coherent: bool,
525}
526
527#[derive(Debug, Clone, Default)]
529pub struct AccountGroups {
530 pub current_assets: Vec<String>,
532 pub non_current_assets: Vec<String>,
534 pub current_liabilities: Vec<String>,
536 pub non_current_liabilities: Vec<String>,
538 pub equity: Vec<String>,
540 pub revenue: Vec<String>,
542 pub cogs: Vec<String>,
544 pub operating_expenses: Vec<String>,
546 pub accounts_receivable: Vec<String>,
548 pub accounts_payable: Vec<String>,
550 pub inventory: Vec<String>,
552 pub fixed_assets: Vec<String>,
554 pub accumulated_depreciation: Vec<String>,
556}
557
558pub fn calculate_dso(ar_balance: Decimal, annual_revenue: Decimal) -> Option<Decimal> {
560 if annual_revenue == Decimal::ZERO {
561 None
562 } else {
563 Some(ar_balance / annual_revenue * dec!(365))
564 }
565}
566
567pub fn calculate_dpo(ap_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
569 if annual_cogs == Decimal::ZERO {
570 None
571 } else {
572 Some(ap_balance / annual_cogs * dec!(365))
573 }
574}
575
576pub fn calculate_dio(inventory_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
578 if annual_cogs == Decimal::ZERO {
579 None
580 } else {
581 Some(inventory_balance / annual_cogs * dec!(365))
582 }
583}
584
585pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
587 dso + dio - dpo
588}
589
590pub fn calculate_gross_margin(revenue: Decimal, cogs: Decimal) -> Option<Decimal> {
592 if revenue == Decimal::ZERO {
593 None
594 } else {
595 Some((revenue - cogs) / revenue)
596 }
597}
598
599pub fn calculate_operating_margin(revenue: Decimal, operating_income: Decimal) -> Option<Decimal> {
601 if revenue == Decimal::ZERO {
602 None
603 } else {
604 Some(operating_income / revenue)
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn test_dso_calculation() {
614 let ar = dec!(123288); let revenue = dec!(1000000); let dso = calculate_dso(ar, revenue).unwrap();
618 assert!((dso - dec!(45)).abs() < dec!(1));
620 }
621
622 #[test]
623 fn test_dpo_calculation() {
624 let ap = dec!(58904); let cogs = dec!(650000); let dpo = calculate_dpo(ap, cogs).unwrap();
628 assert!((dpo - dec!(33)).abs() < dec!(2));
630 }
631
632 #[test]
633 fn test_gross_margin_calculation() {
634 let revenue = dec!(1000000);
635 let cogs = dec!(650000);
636
637 let margin = calculate_gross_margin(revenue, cogs).unwrap();
638 assert_eq!(margin, dec!(0.35));
640 }
641
642 #[test]
643 fn test_ccc_calculation() {
644 let dso = dec!(45);
645 let dio = dec!(60);
646 let dpo = dec!(30);
647
648 let ccc = calculate_ccc(dso, dio, dpo);
649 assert_eq!(ccc, dec!(75));
651 }
652
653 #[test]
654 fn test_dso_rule() {
655 let rule = BalanceRelationshipRule::new_dso_rule(45, 10);
656
657 assert!(rule.is_within_range(dec!(45)));
658 assert!(rule.is_within_range(dec!(35)));
659 assert!(rule.is_within_range(dec!(55)));
660 assert!(!rule.is_within_range(dec!(30)));
661 assert!(!rule.is_within_range(dec!(60)));
662 }
663
664 #[test]
665 fn test_gross_margin_rule() {
666 let rule = BalanceRelationshipRule::new_gross_margin_rule(dec!(0.35), dec!(0.05));
667
668 assert!(rule.is_within_range(dec!(0.35)));
669 assert!(rule.is_within_range(dec!(0.30)));
670 assert!(rule.is_within_range(dec!(0.40)));
671 assert!(!rule.is_within_range(dec!(0.25)));
672 assert!(!rule.is_within_range(dec!(0.45)));
673 }
674
675 #[test]
676 fn test_validation_summary() {
677 let rule1 = BalanceRelationshipRule::new_balance_equation_rule();
678 let rule2 = BalanceRelationshipRule::new_dso_rule(45, 10);
679
680 let results = vec![
681 ValidationResult::pass(&rule1, Decimal::ZERO),
682 ValidationResult::fail(&rule2, dec!(60), "DSO too high".to_string()),
683 ];
684
685 let summary = BalanceCoherenceValidator::summarize_results(&results);
686
687 assert_eq!(summary.total_rules, 2);
688 assert_eq!(summary.passed, 1);
689 assert_eq!(summary.failed, 1);
690 assert!(summary.is_coherent); }
692}