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 {equation_diff:.2}"),
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 {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) // No revenue is technically valid
409                } else {
410                    let gross_profit = snapshot.total_revenue - snapshot.total_expenses; // Simplified
411                    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                // Default calculation using rule accounts
428                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    /// Get a summary of validation results.
461    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/// Summary of validation results.
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct ValidationSummary {
502    /// Total number of rules validated.
503    pub total_rules: usize,
504    /// Rules that passed.
505    pub passed: usize,
506    /// Rules that failed.
507    pub failed: usize,
508    /// Critical failures.
509    pub critical_failures: usize,
510    /// Error failures.
511    pub error_failures: usize,
512    /// Warning failures.
513    pub warning_failures: usize,
514    /// Overall coherence (no critical failures).
515    pub is_coherent: bool,
516}
517
518/// Account groupings for ratio calculations.
519#[derive(Debug, Clone, Default)]
520pub struct AccountGroups {
521    /// Current asset accounts.
522    pub current_assets: Vec<String>,
523    /// Non-current asset accounts.
524    pub non_current_assets: Vec<String>,
525    /// Current liability accounts.
526    pub current_liabilities: Vec<String>,
527    /// Non-current liability accounts.
528    pub non_current_liabilities: Vec<String>,
529    /// Equity accounts.
530    pub equity: Vec<String>,
531    /// Revenue accounts.
532    pub revenue: Vec<String>,
533    /// COGS accounts.
534    pub cogs: Vec<String>,
535    /// Operating expense accounts.
536    pub operating_expenses: Vec<String>,
537    /// AR accounts.
538    pub accounts_receivable: Vec<String>,
539    /// AP accounts.
540    pub accounts_payable: Vec<String>,
541    /// Inventory accounts.
542    pub inventory: Vec<String>,
543    /// Fixed asset accounts.
544    pub fixed_assets: Vec<String>,
545    /// Accumulated depreciation accounts.
546    pub accumulated_depreciation: Vec<String>,
547}
548
549/// Calculate DSO from balances.
550pub 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
558/// Calculate DPO from balances.
559pub 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
567/// Calculate DIO from balances.
568pub 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
576/// Calculate cash conversion cycle.
577pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
578    dso + dio - dpo
579}
580
581/// Calculate gross margin.
582pub 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
590/// Calculate operating margin.
591pub 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); // AR balance
607        let revenue = dec!(1000000); // Annual revenue
608
609        let dso = calculate_dso(ar, revenue).unwrap();
610        // 123288 / 1000000 * 365 = 45.0
611        assert!((dso - dec!(45)).abs() < dec!(1));
612    }
613
614    #[test]
615    fn test_dpo_calculation() {
616        let ap = dec!(58904); // AP balance
617        let cogs = dec!(650000); // Annual COGS
618
619        let dpo = calculate_dpo(ap, cogs).unwrap();
620        // 58904 / 650000 * 365 ≈ 33.1
621        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        // (1000000 - 650000) / 1000000 = 0.35
631        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        // 45 + 60 - 30 = 75 days
642        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); // No critical failures
683    }
684}