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