Skip to main content

datasynth_generators/balance/
opening_balance_generator.rs

1//! Opening balance generator.
2//!
3//! Generates coherent opening balances that:
4//! - Satisfy the balance sheet equation (A = L + E)
5//! - Reflect industry-specific asset compositions
6//! - Support configurable debt-to-equity ratios
7//! - Provide realistic starting positions for simulation
8
9use chrono::{Datelike, NaiveDate};
10use rand::Rng;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14use std::collections::HashMap;
15
16use datasynth_core::models::balance::{
17    AccountBalance, AccountCategory, AccountType, AssetComposition, CapitalStructure,
18    GeneratedOpeningBalance, IndustryType, OpeningBalanceSpec, TargetRatios,
19};
20use datasynth_core::models::ChartOfAccounts;
21
22/// Configuration for opening balance generation.
23#[derive(Debug, Clone)]
24pub struct OpeningBalanceConfig {
25    /// Total assets to generate.
26    pub total_assets: Decimal,
27    /// Industry type for composition defaults.
28    pub industry: IndustryType,
29    /// Custom asset composition (overrides industry defaults).
30    pub asset_composition: Option<AssetComposition>,
31    /// Custom capital structure (overrides industry defaults).
32    pub capital_structure: Option<CapitalStructure>,
33    /// Custom target ratios (overrides industry defaults).
34    pub target_ratios: Option<TargetRatios>,
35    /// Whether to add random variation to amounts.
36    pub add_variation: bool,
37    /// Maximum variation percentage (0.0 to 1.0).
38    pub variation_percent: Decimal,
39}
40
41impl Default for OpeningBalanceConfig {
42    fn default() -> Self {
43        Self {
44            total_assets: dec!(10_000_000),
45            industry: IndustryType::Manufacturing,
46            asset_composition: None,
47            capital_structure: None,
48            target_ratios: None,
49            add_variation: true,
50            variation_percent: dec!(0.05),
51        }
52    }
53}
54
55/// Generator for opening balance sheets.
56pub struct OpeningBalanceGenerator {
57    config: OpeningBalanceConfig,
58    rng: ChaCha8Rng,
59}
60
61impl OpeningBalanceGenerator {
62    /// Creates a new opening balance generator.
63    pub fn new(config: OpeningBalanceConfig, rng: ChaCha8Rng) -> Self {
64        Self { config, rng }
65    }
66
67    /// Creates a generator with default configuration.
68    pub fn with_defaults(rng: ChaCha8Rng) -> Self {
69        Self::new(OpeningBalanceConfig::default(), rng)
70    }
71
72    /// Generates opening balances based on specification.
73    pub fn generate(
74        &mut self,
75        spec: &OpeningBalanceSpec,
76        chart_of_accounts: &ChartOfAccounts,
77        as_of_date: NaiveDate,
78        company_code: &str,
79    ) -> GeneratedOpeningBalance {
80        let mut balances = HashMap::new();
81
82        // Get effective compositions and structures
83        let asset_comp = spec.asset_composition.clone();
84        let capital_struct = spec.capital_structure.clone();
85
86        // Calculate major balance sheet categories
87        let total_assets = spec.total_assets;
88
89        // Assets breakdown (percentages are already in decimal form, e.g., 40 means 40%)
90        let current_assets =
91            self.apply_variation(total_assets * asset_comp.current_assets_percent / dec!(100));
92        let non_current_assets = total_assets - current_assets;
93        let fixed_assets =
94            self.apply_variation(non_current_assets * asset_comp.ppe_percent / dec!(100));
95        let intangible_assets =
96            self.apply_variation(non_current_assets * asset_comp.intangibles_percent / dec!(100));
97        let other_assets = non_current_assets - fixed_assets - intangible_assets;
98
99        // Current assets detail
100        let cash = self.apply_variation(current_assets * asset_comp.cash_percent / dec!(100));
101        let accounts_receivable =
102            self.calculate_ar_from_dso(&spec.target_ratios, current_assets - cash, as_of_date);
103        let inventory =
104            self.apply_variation((current_assets - cash - accounts_receivable) * dec!(0.6));
105        let prepaid_expenses = current_assets - cash - accounts_receivable - inventory;
106
107        // Fixed assets detail
108        let ppe_gross = self.apply_variation(fixed_assets * dec!(1.4)); // Gross amount before depreciation
109        let accumulated_depreciation = ppe_gross - fixed_assets;
110
111        // Liabilities and equity
112        let total_liabilities = total_assets * capital_struct.debt_percent / dec!(100);
113        let total_equity = total_assets - total_liabilities;
114
115        // Current liabilities
116        let current_liabilities = self.apply_variation(total_liabilities * dec!(0.35));
117        let accounts_payable =
118            self.calculate_ap_from_dpo(&spec.target_ratios, current_liabilities, as_of_date);
119        let accrued_expenses = self.apply_variation(current_liabilities * dec!(0.25));
120        let short_term_debt = self.apply_variation(current_liabilities * dec!(0.15));
121        let other_current_liabilities =
122            current_liabilities - accounts_payable - accrued_expenses - short_term_debt;
123
124        // Long-term liabilities
125        let long_term_liabilities = total_liabilities - current_liabilities;
126        let long_term_debt = self.apply_variation(long_term_liabilities * dec!(0.85));
127        let other_long_term_liabilities = long_term_liabilities - long_term_debt;
128
129        // Equity breakdown
130        let common_stock =
131            self.apply_variation(total_equity * capital_struct.common_stock_percent / dec!(100));
132        let retained_earnings = total_equity - common_stock;
133
134        // Create account balances using chart of accounts
135        // Assets (debit balances)
136        self.add_balance(
137            &mut balances,
138            &self.find_account(chart_of_accounts, "1000", "Cash"),
139            AccountType::Asset,
140            cash,
141            as_of_date,
142            company_code,
143        );
144
145        self.add_balance(
146            &mut balances,
147            &self.find_account(chart_of_accounts, "1100", "Accounts Receivable"),
148            AccountType::Asset,
149            accounts_receivable,
150            as_of_date,
151            company_code,
152        );
153
154        self.add_balance(
155            &mut balances,
156            &self.find_account(chart_of_accounts, "1200", "Inventory"),
157            AccountType::Asset,
158            inventory,
159            as_of_date,
160            company_code,
161        );
162
163        self.add_balance(
164            &mut balances,
165            &self.find_account(chart_of_accounts, "1300", "Prepaid Expenses"),
166            AccountType::Asset,
167            prepaid_expenses,
168            as_of_date,
169            company_code,
170        );
171
172        self.add_balance(
173            &mut balances,
174            &self.find_account(chart_of_accounts, "1500", "Property Plant Equipment"),
175            AccountType::Asset,
176            ppe_gross,
177            as_of_date,
178            company_code,
179        );
180
181        self.add_balance(
182            &mut balances,
183            &self.find_account(chart_of_accounts, "1590", "Accumulated Depreciation"),
184            AccountType::ContraAsset,
185            accumulated_depreciation,
186            as_of_date,
187            company_code,
188        );
189
190        self.add_balance(
191            &mut balances,
192            &self.find_account(chart_of_accounts, "1600", "Intangible Assets"),
193            AccountType::Asset,
194            intangible_assets,
195            as_of_date,
196            company_code,
197        );
198
199        self.add_balance(
200            &mut balances,
201            &self.find_account(chart_of_accounts, "1900", "Other Assets"),
202            AccountType::Asset,
203            other_assets,
204            as_of_date,
205            company_code,
206        );
207
208        // Liabilities (credit balances)
209        self.add_balance(
210            &mut balances,
211            &self.find_account(chart_of_accounts, "2000", "Accounts Payable"),
212            AccountType::Liability,
213            accounts_payable,
214            as_of_date,
215            company_code,
216        );
217
218        self.add_balance(
219            &mut balances,
220            &self.find_account(chart_of_accounts, "2100", "Accrued Expenses"),
221            AccountType::Liability,
222            accrued_expenses,
223            as_of_date,
224            company_code,
225        );
226
227        self.add_balance(
228            &mut balances,
229            &self.find_account(chart_of_accounts, "2200", "Short-term Debt"),
230            AccountType::Liability,
231            short_term_debt,
232            as_of_date,
233            company_code,
234        );
235
236        self.add_balance(
237            &mut balances,
238            &self.find_account(chart_of_accounts, "2300", "Other Current Liabilities"),
239            AccountType::Liability,
240            other_current_liabilities,
241            as_of_date,
242            company_code,
243        );
244
245        self.add_balance(
246            &mut balances,
247            &self.find_account(chart_of_accounts, "2500", "Long-term Debt"),
248            AccountType::Liability,
249            long_term_debt,
250            as_of_date,
251            company_code,
252        );
253
254        self.add_balance(
255            &mut balances,
256            &self.find_account(chart_of_accounts, "2900", "Other Long-term Liabilities"),
257            AccountType::Liability,
258            other_long_term_liabilities,
259            as_of_date,
260            company_code,
261        );
262
263        // Equity (credit balances)
264        self.add_balance(
265            &mut balances,
266            &self.find_account(chart_of_accounts, "3000", "Common Stock"),
267            AccountType::Equity,
268            common_stock,
269            as_of_date,
270            company_code,
271        );
272
273        self.add_balance(
274            &mut balances,
275            &self.find_account(chart_of_accounts, "3200", "Retained Earnings"),
276            AccountType::Equity,
277            retained_earnings,
278            as_of_date,
279            company_code,
280        );
281
282        // Calculate totals from balances
283        // Assets = gross assets - contra assets (accumulated depreciation)
284        let gross_assets = self.calculate_total_type(&balances, AccountType::Asset);
285        let contra_assets = self.calculate_total_type(&balances, AccountType::ContraAsset);
286        let total_assets = gross_assets - contra_assets;
287        let total_liabilities = self.calculate_total_type(&balances, AccountType::Liability);
288        let total_equity = self.calculate_total_type(&balances, AccountType::Equity);
289        let is_balanced = (total_assets - total_liabilities - total_equity).abs() < dec!(1.00);
290
291        // Convert AccountBalance map to simple Decimal map
292        let simple_balances: HashMap<String, Decimal> = balances
293            .iter()
294            .map(|(k, v)| (k.clone(), v.closing_balance))
295            .collect();
296
297        // Calculate actual ratios
298        let calculated_ratios = self.calculate_ratios_simple(
299            &simple_balances,
300            total_assets,
301            total_liabilities,
302            total_equity,
303        );
304
305        GeneratedOpeningBalance {
306            company_code: company_code.to_string(),
307            as_of_date,
308            balances: simple_balances,
309            total_assets,
310            total_liabilities,
311            total_equity,
312            is_balanced,
313            calculated_ratios,
314        }
315    }
316
317    /// Calculate total for an account type.
318    fn calculate_total_type(
319        &self,
320        balances: &HashMap<String, AccountBalance>,
321        account_type: AccountType,
322    ) -> Decimal {
323        balances
324            .values()
325            .filter(|b| b.account_type == account_type)
326            .map(|b| b.closing_balance)
327            .sum()
328    }
329
330    /// Generates opening balances from configuration defaults.
331    pub fn generate_from_config(
332        &mut self,
333        chart_of_accounts: &ChartOfAccounts,
334        as_of_date: NaiveDate,
335        company_code: &str,
336    ) -> GeneratedOpeningBalance {
337        let spec = OpeningBalanceSpec::for_industry(self.config.total_assets, self.config.industry);
338        self.generate(&spec, chart_of_accounts, as_of_date, company_code)
339    }
340
341    /// Generates opening balances for multiple companies.
342    pub fn generate_for_companies(
343        &mut self,
344        specs: &[(String, OpeningBalanceSpec)],
345        chart_of_accounts: &ChartOfAccounts,
346        as_of_date: NaiveDate,
347    ) -> Vec<GeneratedOpeningBalance> {
348        specs
349            .iter()
350            .map(|(company_code, spec)| {
351                self.generate(spec, chart_of_accounts, as_of_date, company_code)
352            })
353            .collect()
354    }
355
356    /// Applies random variation to an amount if configured.
357    fn apply_variation(&mut self, amount: Decimal) -> Decimal {
358        if !self.config.add_variation || self.config.variation_percent == Decimal::ZERO {
359            return amount;
360        }
361
362        let variation_range = amount * self.config.variation_percent;
363        let random_factor: f64 = self.rng.gen_range(-1.0..1.0);
364        let variation = variation_range * Decimal::try_from(random_factor).unwrap_or_default();
365
366        (amount + variation).max(Decimal::ZERO)
367    }
368
369    /// Calculates AR balance from target DSO.
370    fn calculate_ar_from_dso(
371        &self,
372        target_ratios: &TargetRatios,
373        max_ar: Decimal,
374        _as_of_date: NaiveDate,
375    ) -> Decimal {
376        // DSO = (AR / Annual Revenue) * 365
377        // AR = (DSO * Annual Revenue) / 365
378        // Estimate annual revenue as 4x current assets (rough approximation)
379        let estimated_annual_revenue = max_ar * dec!(10);
380        let target_ar =
381            (Decimal::from(target_ratios.target_dso_days) * estimated_annual_revenue) / dec!(365);
382
383        // Cap at reasonable percentage of current assets
384        target_ar.min(max_ar * dec!(0.7))
385    }
386
387    /// Calculates AP balance from target DPO.
388    fn calculate_ap_from_dpo(
389        &self,
390        target_ratios: &TargetRatios,
391        current_liabilities: Decimal,
392        _as_of_date: NaiveDate,
393    ) -> Decimal {
394        // DPO = (AP / COGS) * 365
395        // AP = (DPO * COGS) / 365
396        // Estimate COGS as related to current liabilities
397        let estimated_cogs = current_liabilities * dec!(8);
398        let target_ap = (Decimal::from(target_ratios.target_dpo_days) * estimated_cogs) / dec!(365);
399
400        // Cap at reasonable percentage of current liabilities
401        target_ap.min(current_liabilities * dec!(0.5))
402    }
403
404    /// Finds an account code from the chart of accounts or uses a default.
405    fn find_account(
406        &self,
407        chart_of_accounts: &ChartOfAccounts,
408        default_code: &str,
409        description: &str,
410    ) -> String {
411        // Try to find account by description pattern
412        for account in &chart_of_accounts.accounts {
413            if account
414                .description()
415                .to_lowercase()
416                .contains(&description.to_lowercase())
417            {
418                return account.account_code().to_string();
419            }
420        }
421
422        // Fall back to default code
423        default_code.to_string()
424    }
425
426    /// Adds a balance to the map.
427    fn add_balance(
428        &self,
429        balances: &mut HashMap<String, AccountBalance>,
430        account_code: &str,
431        account_type: AccountType,
432        amount: Decimal,
433        as_of_date: NaiveDate,
434        company_code: &str,
435    ) {
436        use chrono::Datelike;
437
438        if amount == Decimal::ZERO {
439            return;
440        }
441
442        let mut balance = AccountBalance::new(
443            company_code.to_string(),
444            account_code.to_string(),
445            account_type,
446            "USD".to_string(),
447            as_of_date.year(),
448            as_of_date.month(),
449        );
450        balance.opening_balance = amount;
451        balance.closing_balance = amount;
452
453        balances.insert(account_code.to_string(), balance);
454    }
455
456    /// Calculates financial ratios from the generated balances.
457    fn calculate_ratios_simple(
458        &self,
459        balances: &HashMap<String, Decimal>,
460        _total_assets: Decimal,
461        _total_liabilities: Decimal,
462        total_equity: Decimal,
463    ) -> datasynth_core::models::balance::CalculatedRatios {
464        // Calculate current ratio
465        let current_assets = self.sum_balances(balances, &["1000", "1100", "1200", "1300"]);
466        let current_liabilities = self.sum_balances(balances, &["2000", "2100", "2200", "2300"]);
467        let current_ratio = if current_liabilities > Decimal::ZERO {
468            Some(current_assets / current_liabilities)
469        } else {
470            None
471        };
472
473        // Calculate quick ratio (current assets - inventory) / current liabilities
474        let inventory = self.get_balance(balances, "1200");
475        let quick_ratio = if current_liabilities > Decimal::ZERO {
476            Some((current_assets - inventory) / current_liabilities)
477        } else {
478            None
479        };
480
481        // Calculate debt to equity
482        let total_debt = self.sum_balances(balances, &["2200", "2500"]);
483        let debt_to_equity = if total_equity > Decimal::ZERO {
484            Some(total_debt / total_equity)
485        } else {
486            None
487        };
488
489        // Working capital = current assets - current liabilities
490        let working_capital = current_assets - current_liabilities;
491
492        datasynth_core::models::balance::CalculatedRatios {
493            current_ratio,
494            quick_ratio,
495            debt_to_equity,
496            working_capital,
497        }
498    }
499
500    /// Sums balances for a set of account codes.
501    fn sum_balances(
502        &self,
503        balances: &HashMap<String, Decimal>,
504        account_prefixes: &[&str],
505    ) -> Decimal {
506        balances
507            .iter()
508            .filter(|(code, _)| {
509                account_prefixes
510                    .iter()
511                    .any(|prefix| code.starts_with(prefix))
512            })
513            .map(|(_, amount)| amount.abs())
514            .sum()
515    }
516
517    /// Gets a single account balance.
518    fn get_balance(&self, balances: &HashMap<String, Decimal>, account_prefix: &str) -> Decimal {
519        balances
520            .iter()
521            .filter(|(code, _)| code.starts_with(account_prefix))
522            .map(|(_, amount)| amount.abs())
523            .sum()
524    }
525}
526
527/// Builder for opening balance specifications.
528pub struct OpeningBalanceSpecBuilder {
529    company_code: String,
530    as_of_date: NaiveDate,
531    fiscal_year: i32,
532    currency: String,
533    total_assets: Decimal,
534    industry: IndustryType,
535    asset_composition: Option<AssetComposition>,
536    capital_structure: Option<CapitalStructure>,
537    target_ratios: Option<TargetRatios>,
538    account_overrides: HashMap<String, datasynth_core::models::balance::AccountSpec>,
539}
540
541impl OpeningBalanceSpecBuilder {
542    /// Creates a new builder with required parameters.
543    pub fn new(
544        company_code: impl Into<String>,
545        as_of_date: NaiveDate,
546        total_assets: Decimal,
547        industry: IndustryType,
548    ) -> Self {
549        let year = as_of_date.year();
550        Self {
551            company_code: company_code.into(),
552            as_of_date,
553            fiscal_year: year,
554            currency: "USD".to_string(),
555            total_assets,
556            industry,
557            asset_composition: None,
558            capital_structure: None,
559            target_ratios: None,
560            account_overrides: HashMap::new(),
561        }
562    }
563
564    /// Sets the currency.
565    pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
566        self.currency = currency.into();
567        self
568    }
569
570    /// Sets the fiscal year.
571    pub fn with_fiscal_year(mut self, fiscal_year: i32) -> Self {
572        self.fiscal_year = fiscal_year;
573        self
574    }
575
576    /// Sets custom asset composition.
577    pub fn with_asset_composition(mut self, composition: AssetComposition) -> Self {
578        self.asset_composition = Some(composition);
579        self
580    }
581
582    /// Sets custom capital structure.
583    pub fn with_capital_structure(mut self, structure: CapitalStructure) -> Self {
584        self.capital_structure = Some(structure);
585        self
586    }
587
588    /// Sets custom target ratios.
589    pub fn with_target_ratios(mut self, ratios: TargetRatios) -> Self {
590        self.target_ratios = Some(ratios);
591        self
592    }
593
594    /// Adds an account override with a fixed balance.
595    pub fn with_account_override(
596        mut self,
597        account_code: impl Into<String>,
598        description: impl Into<String>,
599        account_type: AccountType,
600        fixed_balance: Decimal,
601    ) -> Self {
602        let code = account_code.into();
603        self.account_overrides.insert(
604            code.clone(),
605            datasynth_core::models::balance::AccountSpec {
606                account_code: code,
607                description: description.into(),
608                account_type,
609                category: AccountCategory::CurrentAssets,
610                fixed_balance: Some(fixed_balance),
611                category_percent: None,
612                total_assets_percent: None,
613            },
614        );
615        self
616    }
617
618    /// Builds the opening balance specification.
619    pub fn build(self) -> OpeningBalanceSpec {
620        let industry_defaults = OpeningBalanceSpec::for_industry(self.total_assets, self.industry);
621
622        OpeningBalanceSpec {
623            company_code: self.company_code,
624            as_of_date: self.as_of_date,
625            fiscal_year: self.fiscal_year,
626            currency: self.currency,
627            total_assets: self.total_assets,
628            industry: self.industry,
629            asset_composition: self
630                .asset_composition
631                .unwrap_or(industry_defaults.asset_composition),
632            capital_structure: self
633                .capital_structure
634                .unwrap_or(industry_defaults.capital_structure),
635            target_ratios: self
636                .target_ratios
637                .unwrap_or(industry_defaults.target_ratios),
638            account_overrides: self.account_overrides,
639        }
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use datasynth_core::models::{CoAComplexity, IndustrySector};
647    use rand::SeedableRng;
648
649    fn create_test_chart() -> ChartOfAccounts {
650        ChartOfAccounts::new(
651            "TEST-COA".to_string(),
652            "Test Chart of Accounts".to_string(),
653            "US".to_string(),
654            IndustrySector::Manufacturing,
655            CoAComplexity::Medium,
656        )
657    }
658
659    #[test]
660    fn test_generate_opening_balances() {
661        let rng = ChaCha8Rng::seed_from_u64(12345);
662        let config = OpeningBalanceConfig {
663            add_variation: false,
664            ..Default::default()
665        };
666        let mut generator = OpeningBalanceGenerator::new(config, rng);
667
668        let spec = OpeningBalanceSpec::for_industry(dec!(1_000_000), IndustryType::Manufacturing);
669        let chart = create_test_chart();
670        let result = generator.generate(
671            &spec,
672            &chart,
673            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
674            "1000",
675        );
676
677        // Verify balance sheet is balanced
678        assert!(result.is_balanced);
679
680        // Verify total assets match
681        assert!(
682            (result.total_assets - dec!(1_000_000)).abs() < dec!(1000),
683            "Total assets should be close to spec"
684        );
685    }
686
687    #[test]
688    fn test_industry_specific_composition() {
689        let rng = ChaCha8Rng::seed_from_u64(54321);
690        let _generator = OpeningBalanceGenerator::with_defaults(rng);
691
692        let tech_spec = OpeningBalanceSpec::for_industry(dec!(1_000_000), IndustryType::Technology);
693        let mfg_spec =
694            OpeningBalanceSpec::for_industry(dec!(1_000_000), IndustryType::Manufacturing);
695
696        // Tech should have higher intangible assets
697        assert!(
698            tech_spec.asset_composition.intangibles_percent
699                > mfg_spec.asset_composition.intangibles_percent
700        );
701
702        // Manufacturing should have higher fixed assets (PPE)
703        assert!(mfg_spec.asset_composition.ppe_percent > tech_spec.asset_composition.ppe_percent);
704    }
705
706    #[test]
707    fn test_builder_pattern() {
708        let as_of = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
709
710        let spec =
711            OpeningBalanceSpecBuilder::new("TEST", as_of, dec!(5_000_000), IndustryType::Retail)
712                .with_target_ratios(TargetRatios {
713                    target_dso_days: 30,
714                    target_dpo_days: 45,
715                    ..TargetRatios::for_industry(IndustryType::Retail)
716                })
717                .with_account_override("1000", "Cash", AccountType::Asset, dec!(500_000))
718                .build();
719
720        assert_eq!(spec.total_assets, dec!(5_000_000));
721        assert_eq!(spec.target_ratios.target_dso_days, 30);
722        assert_eq!(spec.account_overrides.len(), 1);
723    }
724}