Skip to main content

datasynth_core/models/balance/
balance_relationship.rs

1//! Balance relationship rules and coherence validation.
2//!
3//! Defines rules for validating relationships between balance sheet and
4//! income statement items (DSO, DPO, gross margin, etc.).
5
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9
10use super::account_balance::BalanceSnapshot;
11
12/// Balance relationship rule types.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RelationshipType {
16    /// Days Sales Outstanding (AR / Revenue * 365).
17    DaysSalesOutstanding,
18    /// Days Payable Outstanding (AP / COGS * 365).
19    DaysPayableOutstanding,
20    /// Days Inventory Outstanding (Inventory / COGS * 365).
21    DaysInventoryOutstanding,
22    /// Cash Conversion Cycle (DSO + DIO - DPO).
23    CashConversionCycle,
24    /// Gross Margin ((Revenue - COGS) / Revenue).
25    GrossMargin,
26    /// Operating Margin (Operating Income / Revenue).
27    OperatingMargin,
28    /// Net Margin (Net Income / Revenue).
29    NetMargin,
30    /// Current Ratio (Current Assets / Current Liabilities).
31    CurrentRatio,
32    /// Quick Ratio ((Current Assets - Inventory) / Current Liabilities).
33    QuickRatio,
34    /// Debt to Equity (Total Liabilities / Total Equity).
35    DebtToEquity,
36    /// Interest Coverage (EBIT / Interest Expense).
37    InterestCoverage,
38    /// Asset Turnover (Revenue / Total Assets).
39    AssetTurnover,
40    /// Return on Assets (Net Income / Total Assets).
41    ReturnOnAssets,
42    /// Return on Equity (Net Income / Total Equity).
43    ReturnOnEquity,
44    /// Depreciation to Fixed Assets (Annual Depreciation / Gross Fixed Assets).
45    DepreciationRate,
46    /// Balance Sheet Equation (Assets = Liabilities + Equity).
47    BalanceSheetEquation,
48    /// Retained Earnings Roll-forward.
49    RetainedEarningsRollForward,
50}
51
52impl RelationshipType {
53    /// Get the display name for this relationship type.
54    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    /// Is this a critical validation (must pass)?
77    pub fn is_critical(&self) -> bool {
78        matches!(
79            self,
80            Self::BalanceSheetEquation | Self::RetainedEarningsRollForward
81        )
82    }
83}
84
85/// A balance relationship rule definition.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct BalanceRelationshipRule {
88    /// Rule identifier.
89    pub rule_id: String,
90    /// Rule name.
91    pub name: String,
92    /// Relationship type.
93    pub relationship_type: RelationshipType,
94    /// Target value (if applicable).
95    pub target_value: Option<Decimal>,
96    /// Minimum acceptable value.
97    pub min_value: Option<Decimal>,
98    /// Maximum acceptable value.
99    pub max_value: Option<Decimal>,
100    /// Tolerance for deviation from target.
101    pub tolerance: Decimal,
102    /// Is this rule enabled?
103    pub enabled: bool,
104    /// Severity if rule fails.
105    pub severity: RuleSeverity,
106    /// Account codes for numerator calculation.
107    pub numerator_accounts: Vec<String>,
108    /// Account codes for denominator calculation.
109    pub denominator_accounts: Vec<String>,
110    /// Multiplier (e.g., 365 for DSO).
111    pub multiplier: Decimal,
112}
113
114impl BalanceRelationshipRule {
115    /// Create a new DSO rule.
116    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()], // AR accounts
128            denominator_accounts: vec!["4100".to_string()], // Revenue accounts
129            multiplier: dec!(365),
130        }
131    }
132
133    /// Create a new DPO rule.
134    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()], // AP accounts
146            denominator_accounts: vec!["5100".to_string()], // COGS accounts
147            multiplier: dec!(365),
148        }
149    }
150
151    /// Create a new gross margin rule.
152    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()], // Revenue - COGS
164            denominator_accounts: vec!["4100".to_string()],                   // Revenue
165            multiplier: Decimal::ONE,
166        }
167    }
168
169    /// Create balance sheet equation rule.
170    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    /// Check if a calculated value is within acceptable range.
188    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    /// Get the deviation from target.
195    pub fn deviation_from_target(&self, value: Decimal) -> Option<Decimal> {
196        self.target_value.map(|target| value - target)
197    }
198}
199
200/// Severity level for rule violations.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum RuleSeverity {
204    /// Informational only.
205    Info,
206    /// Warning - should be investigated.
207    #[default]
208    Warning,
209    /// Error - significant issue.
210    Error,
211    /// Critical - must be resolved.
212    Critical,
213}
214
215/// Result of validating a balance relationship.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ValidationResult {
218    /// Rule that was validated.
219    pub rule_id: String,
220    /// Rule name.
221    pub rule_name: String,
222    /// Relationship type.
223    pub relationship_type: RelationshipType,
224    /// Calculated value.
225    pub calculated_value: Decimal,
226    /// Target value.
227    pub target_value: Option<Decimal>,
228    /// Is the value within acceptable range?
229    pub is_valid: bool,
230    /// Deviation from target.
231    pub deviation: Option<Decimal>,
232    /// Deviation percentage.
233    pub deviation_percent: Option<Decimal>,
234    /// Severity.
235    pub severity: RuleSeverity,
236    /// Validation message.
237    pub message: String,
238}
239
240impl ValidationResult {
241    /// Create a passing result.
242    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    /// Create a failing result.
270    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
299/// Balance coherence validator.
300pub struct BalanceCoherenceValidator {
301    /// Rules to validate.
302    rules: Vec<BalanceRelationshipRule>,
303}
304
305impl BalanceCoherenceValidator {
306    /// Create a new validator with default rules.
307    pub fn new() -> Self {
308        Self { rules: Vec::new() }
309    }
310
311    /// Add a rule to the validator.
312    pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
313        self.rules.push(rule);
314    }
315
316    /// Add standard rules for an industry.
317    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    /// Validate a balance snapshot against all rules.
332    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    /// Validate a single rule against a snapshot.
348    fn validate_rule(
349        &self,
350        rule: &BalanceRelationshipRule,
351        snapshot: &BalanceSnapshot,
352    ) -> ValidationResult {
353        match rule.relationship_type {
354            RelationshipType::BalanceSheetEquation => {
355                // A = L + E + Net Income
356                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; // Simplified
369                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) // No revenue is technically valid
412                } else {
413                    let gross_profit = snapshot.total_revenue - snapshot.total_expenses; // Simplified
414                    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                // Default calculation using rule accounts
431                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    /// Get a summary of validation results.
464    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/// Summary of validation results.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct ValidationSummary {
505    /// Total number of rules validated.
506    pub total_rules: usize,
507    /// Rules that passed.
508    pub passed: usize,
509    /// Rules that failed.
510    pub failed: usize,
511    /// Critical failures.
512    pub critical_failures: usize,
513    /// Error failures.
514    pub error_failures: usize,
515    /// Warning failures.
516    pub warning_failures: usize,
517    /// Overall coherence (no critical failures).
518    pub is_coherent: bool,
519}
520
521/// Account groupings for ratio calculations.
522#[derive(Debug, Clone, Default)]
523pub struct AccountGroups {
524    /// Current asset accounts.
525    pub current_assets: Vec<String>,
526    /// Non-current asset accounts.
527    pub non_current_assets: Vec<String>,
528    /// Current liability accounts.
529    pub current_liabilities: Vec<String>,
530    /// Non-current liability accounts.
531    pub non_current_liabilities: Vec<String>,
532    /// Equity accounts.
533    pub equity: Vec<String>,
534    /// Revenue accounts.
535    pub revenue: Vec<String>,
536    /// COGS accounts.
537    pub cogs: Vec<String>,
538    /// Operating expense accounts.
539    pub operating_expenses: Vec<String>,
540    /// AR accounts.
541    pub accounts_receivable: Vec<String>,
542    /// AP accounts.
543    pub accounts_payable: Vec<String>,
544    /// Inventory accounts.
545    pub inventory: Vec<String>,
546    /// Fixed asset accounts.
547    pub fixed_assets: Vec<String>,
548    /// Accumulated depreciation accounts.
549    pub accumulated_depreciation: Vec<String>,
550}
551
552/// Calculate DSO from balances.
553pub 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
561/// Calculate DPO from balances.
562pub 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
570/// Calculate DIO from balances.
571pub 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
579/// Calculate cash conversion cycle.
580pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
581    dso + dio - dpo
582}
583
584/// Calculate gross margin.
585pub 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
593/// Calculate operating margin.
594pub 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); // AR balance
610        let revenue = dec!(1000000); // Annual revenue
611
612        let dso = calculate_dso(ar, revenue).unwrap();
613        // 123288 / 1000000 * 365 = 45.0
614        assert!((dso - dec!(45)).abs() < dec!(1));
615    }
616
617    #[test]
618    fn test_dpo_calculation() {
619        let ap = dec!(58904); // AP balance
620        let cogs = dec!(650000); // Annual COGS
621
622        let dpo = calculate_dpo(ap, cogs).unwrap();
623        // 58904 / 650000 * 365 ≈ 33.1
624        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        // (1000000 - 650000) / 1000000 = 0.35
634        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        // 45 + 60 - 30 = 75 days
645        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); // No critical failures
686    }
687}