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.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    /// 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    /// Account groupings for calculations.
304    #[allow(dead_code)]
305    account_groups: AccountGroups,
306}
307
308impl BalanceCoherenceValidator {
309    /// Create a new validator with default rules.
310    pub fn new() -> Self {
311        Self {
312            rules: Vec::new(),
313            account_groups: AccountGroups::default(),
314        }
315    }
316
317    /// Add a rule to the validator.
318    pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
319        self.rules.push(rule);
320    }
321
322    /// Add standard rules for an industry.
323    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    /// Validate a balance snapshot against all rules.
338    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    /// Validate a single rule against a snapshot.
354    fn validate_rule(
355        &self,
356        rule: &BalanceRelationshipRule,
357        snapshot: &BalanceSnapshot,
358    ) -> ValidationResult {
359        match rule.relationship_type {
360            RelationshipType::BalanceSheetEquation => {
361                // A = L + E + Net Income
362                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; // Simplified
375                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) // No revenue is technically valid
418                } else {
419                    let gross_profit = snapshot.total_revenue - snapshot.total_expenses; // Simplified
420                    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                // Default calculation using rule accounts
437                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    /// Get a summary of validation results.
470    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/// Summary of validation results.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct ValidationSummary {
511    /// Total number of rules validated.
512    pub total_rules: usize,
513    /// Rules that passed.
514    pub passed: usize,
515    /// Rules that failed.
516    pub failed: usize,
517    /// Critical failures.
518    pub critical_failures: usize,
519    /// Error failures.
520    pub error_failures: usize,
521    /// Warning failures.
522    pub warning_failures: usize,
523    /// Overall coherence (no critical failures).
524    pub is_coherent: bool,
525}
526
527/// Account groupings for ratio calculations.
528#[derive(Debug, Clone, Default)]
529pub struct AccountGroups {
530    /// Current asset accounts.
531    pub current_assets: Vec<String>,
532    /// Non-current asset accounts.
533    pub non_current_assets: Vec<String>,
534    /// Current liability accounts.
535    pub current_liabilities: Vec<String>,
536    /// Non-current liability accounts.
537    pub non_current_liabilities: Vec<String>,
538    /// Equity accounts.
539    pub equity: Vec<String>,
540    /// Revenue accounts.
541    pub revenue: Vec<String>,
542    /// COGS accounts.
543    pub cogs: Vec<String>,
544    /// Operating expense accounts.
545    pub operating_expenses: Vec<String>,
546    /// AR accounts.
547    pub accounts_receivable: Vec<String>,
548    /// AP accounts.
549    pub accounts_payable: Vec<String>,
550    /// Inventory accounts.
551    pub inventory: Vec<String>,
552    /// Fixed asset accounts.
553    pub fixed_assets: Vec<String>,
554    /// Accumulated depreciation accounts.
555    pub accumulated_depreciation: Vec<String>,
556}
557
558/// Calculate DSO from balances.
559pub 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
567/// Calculate DPO from balances.
568pub 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
576/// Calculate DIO from balances.
577pub 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
585/// Calculate cash conversion cycle.
586pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
587    dso + dio - dpo
588}
589
590/// Calculate gross margin.
591pub 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
599/// Calculate operating margin.
600pub 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); // AR balance
615        let revenue = dec!(1000000); // Annual revenue
616
617        let dso = calculate_dso(ar, revenue).unwrap();
618        // 123288 / 1000000 * 365 = 45.0
619        assert!((dso - dec!(45)).abs() < dec!(1));
620    }
621
622    #[test]
623    fn test_dpo_calculation() {
624        let ap = dec!(58904); // AP balance
625        let cogs = dec!(650000); // Annual COGS
626
627        let dpo = calculate_dpo(ap, cogs).unwrap();
628        // 58904 / 650000 * 365 ≈ 33.1
629        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        // (1000000 - 650000) / 1000000 = 0.35
639        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        // 45 + 60 - 30 = 75 days
650        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); // No critical failures
691    }
692}