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 {equation_diff:.2}"),
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 {ratio:.2} is outside acceptable range"),
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!("Debt-to-equity ratio {ratio:.2} is outside acceptable range"),
402 )
403 }
404 }
405 }
406 RelationshipType::GrossMargin => {
407 if snapshot.total_revenue == Decimal::ZERO {
408 ValidationResult::pass(rule, Decimal::ZERO) } else {
410 let gross_profit = snapshot.total_revenue - snapshot.total_expenses; let margin = gross_profit / snapshot.total_revenue;
412 if rule.is_within_range(margin) {
413 ValidationResult::pass(rule, margin)
414 } else {
415 ValidationResult::fail(
416 rule,
417 margin,
418 format!(
419 "Gross margin {:.1}% is outside target range",
420 margin * dec!(100)
421 ),
422 )
423 }
424 }
425 }
426 _ => {
427 let numerator: Decimal = rule
429 .numerator_accounts
430 .iter()
431 .filter_map(|code| snapshot.get_balance(code))
432 .map(|b| b.closing_balance)
433 .sum();
434
435 let denominator: Decimal = rule
436 .denominator_accounts
437 .iter()
438 .filter_map(|code| snapshot.get_balance(code))
439 .map(|b| b.closing_balance)
440 .sum();
441
442 if denominator == Decimal::ZERO {
443 ValidationResult::fail(rule, Decimal::ZERO, "Denominator is zero".to_string())
444 } else {
445 let value = numerator / denominator * rule.multiplier;
446 if rule.is_within_range(value) {
447 ValidationResult::pass(rule, value)
448 } else {
449 ValidationResult::fail(
450 rule,
451 value,
452 format!("{} = {:.2} is outside acceptable range", rule.name, value),
453 )
454 }
455 }
456 }
457 }
458 }
459
460 pub fn summarize_results(results: &[ValidationResult]) -> ValidationSummary {
462 let total = results.len();
463 let passed = results.iter().filter(|r| r.is_valid).count();
464 let failed = total - passed;
465
466 let critical_failures = results
467 .iter()
468 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Critical)
469 .count();
470
471 let error_failures = results
472 .iter()
473 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Error)
474 .count();
475
476 let warning_failures = results
477 .iter()
478 .filter(|r| !r.is_valid && r.severity == RuleSeverity::Warning)
479 .count();
480
481 ValidationSummary {
482 total_rules: total,
483 passed,
484 failed,
485 critical_failures,
486 error_failures,
487 warning_failures,
488 is_coherent: critical_failures == 0,
489 }
490 }
491}
492
493impl Default for BalanceCoherenceValidator {
494 fn default() -> Self {
495 Self::new()
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct ValidationSummary {
502 pub total_rules: usize,
504 pub passed: usize,
506 pub failed: usize,
508 pub critical_failures: usize,
510 pub error_failures: usize,
512 pub warning_failures: usize,
514 pub is_coherent: bool,
516}
517
518#[derive(Debug, Clone, Default)]
520pub struct AccountGroups {
521 pub current_assets: Vec<String>,
523 pub non_current_assets: Vec<String>,
525 pub current_liabilities: Vec<String>,
527 pub non_current_liabilities: Vec<String>,
529 pub equity: Vec<String>,
531 pub revenue: Vec<String>,
533 pub cogs: Vec<String>,
535 pub operating_expenses: Vec<String>,
537 pub accounts_receivable: Vec<String>,
539 pub accounts_payable: Vec<String>,
541 pub inventory: Vec<String>,
543 pub fixed_assets: Vec<String>,
545 pub accumulated_depreciation: Vec<String>,
547}
548
549pub fn calculate_dso(ar_balance: Decimal, annual_revenue: Decimal) -> Option<Decimal> {
551 if annual_revenue == Decimal::ZERO {
552 None
553 } else {
554 Some(ar_balance / annual_revenue * dec!(365))
555 }
556}
557
558pub fn calculate_dpo(ap_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
560 if annual_cogs == Decimal::ZERO {
561 None
562 } else {
563 Some(ap_balance / annual_cogs * dec!(365))
564 }
565}
566
567pub fn calculate_dio(inventory_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
569 if annual_cogs == Decimal::ZERO {
570 None
571 } else {
572 Some(inventory_balance / annual_cogs * dec!(365))
573 }
574}
575
576pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
578 dso + dio - dpo
579}
580
581pub fn calculate_gross_margin(revenue: Decimal, cogs: Decimal) -> Option<Decimal> {
583 if revenue == Decimal::ZERO {
584 None
585 } else {
586 Some((revenue - cogs) / revenue)
587 }
588}
589
590pub fn calculate_operating_margin(revenue: Decimal, operating_income: Decimal) -> Option<Decimal> {
592 if revenue == Decimal::ZERO {
593 None
594 } else {
595 Some(operating_income / revenue)
596 }
597}
598
599#[cfg(test)]
600#[allow(clippy::unwrap_used)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn test_dso_calculation() {
606 let ar = dec!(123288); let revenue = dec!(1000000); let dso = calculate_dso(ar, revenue).unwrap();
610 assert!((dso - dec!(45)).abs() < dec!(1));
612 }
613
614 #[test]
615 fn test_dpo_calculation() {
616 let ap = dec!(58904); let cogs = dec!(650000); let dpo = calculate_dpo(ap, cogs).unwrap();
620 assert!((dpo - dec!(33)).abs() < dec!(2));
622 }
623
624 #[test]
625 fn test_gross_margin_calculation() {
626 let revenue = dec!(1000000);
627 let cogs = dec!(650000);
628
629 let margin = calculate_gross_margin(revenue, cogs).unwrap();
630 assert_eq!(margin, dec!(0.35));
632 }
633
634 #[test]
635 fn test_ccc_calculation() {
636 let dso = dec!(45);
637 let dio = dec!(60);
638 let dpo = dec!(30);
639
640 let ccc = calculate_ccc(dso, dio, dpo);
641 assert_eq!(ccc, dec!(75));
643 }
644
645 #[test]
646 fn test_dso_rule() {
647 let rule = BalanceRelationshipRule::new_dso_rule(45, 10);
648
649 assert!(rule.is_within_range(dec!(45)));
650 assert!(rule.is_within_range(dec!(35)));
651 assert!(rule.is_within_range(dec!(55)));
652 assert!(!rule.is_within_range(dec!(30)));
653 assert!(!rule.is_within_range(dec!(60)));
654 }
655
656 #[test]
657 fn test_gross_margin_rule() {
658 let rule = BalanceRelationshipRule::new_gross_margin_rule(dec!(0.35), dec!(0.05));
659
660 assert!(rule.is_within_range(dec!(0.35)));
661 assert!(rule.is_within_range(dec!(0.30)));
662 assert!(rule.is_within_range(dec!(0.40)));
663 assert!(!rule.is_within_range(dec!(0.25)));
664 assert!(!rule.is_within_range(dec!(0.45)));
665 }
666
667 #[test]
668 fn test_validation_summary() {
669 let rule1 = BalanceRelationshipRule::new_balance_equation_rule();
670 let rule2 = BalanceRelationshipRule::new_dso_rule(45, 10);
671
672 let results = vec![
673 ValidationResult::pass(&rule1, Decimal::ZERO),
674 ValidationResult::fail(&rule2, dec!(60), "DSO too high".to_string()),
675 ];
676
677 let summary = BalanceCoherenceValidator::summarize_results(&results);
678
679 assert_eq!(summary.total_rules, 2);
680 assert_eq!(summary.passed, 1);
681 assert_eq!(summary.failed, 1);
682 assert!(summary.is_coherent); }
684}