1use 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#[derive(Debug, Clone)]
24pub struct OpeningBalanceConfig {
25 pub total_assets: Decimal,
27 pub industry: IndustryType,
29 pub asset_composition: Option<AssetComposition>,
31 pub capital_structure: Option<CapitalStructure>,
33 pub target_ratios: Option<TargetRatios>,
35 pub add_variation: bool,
37 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
55pub struct OpeningBalanceGenerator {
57 config: OpeningBalanceConfig,
58 rng: ChaCha8Rng,
59}
60
61impl OpeningBalanceGenerator {
62 pub fn new(config: OpeningBalanceConfig, rng: ChaCha8Rng) -> Self {
64 Self { config, rng }
65 }
66
67 pub fn with_defaults(rng: ChaCha8Rng) -> Self {
69 Self::new(OpeningBalanceConfig::default(), rng)
70 }
71
72 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 let asset_comp = spec.asset_composition.clone();
84 let capital_struct = spec.capital_structure.clone();
85
86 let total_assets = spec.total_assets;
88
89 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 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 let ppe_gross = self.apply_variation(fixed_assets * dec!(1.4)); let accumulated_depreciation = ppe_gross - fixed_assets;
110
111 let total_liabilities = total_assets * capital_struct.debt_percent / dec!(100);
113 let total_equity = total_assets - total_liabilities;
114
115 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 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 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 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 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 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 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 let simple_balances: HashMap<String, Decimal> = balances
293 .iter()
294 .map(|(k, v)| (k.clone(), v.closing_balance))
295 .collect();
296
297 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 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 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 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 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 fn calculate_ar_from_dso(
371 &self,
372 target_ratios: &TargetRatios,
373 max_ar: Decimal,
374 _as_of_date: NaiveDate,
375 ) -> Decimal {
376 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 target_ar.min(max_ar * dec!(0.7))
385 }
386
387 fn calculate_ap_from_dpo(
389 &self,
390 target_ratios: &TargetRatios,
391 current_liabilities: Decimal,
392 _as_of_date: NaiveDate,
393 ) -> Decimal {
394 let estimated_cogs = current_liabilities * dec!(8);
398 let target_ap = (Decimal::from(target_ratios.target_dpo_days) * estimated_cogs) / dec!(365);
399
400 target_ap.min(current_liabilities * dec!(0.5))
402 }
403
404 fn find_account(
406 &self,
407 chart_of_accounts: &ChartOfAccounts,
408 default_code: &str,
409 description: &str,
410 ) -> String {
411 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 default_code.to_string()
424 }
425
426 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 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 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 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 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 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 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 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
527pub 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 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 pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
566 self.currency = currency.into();
567 self
568 }
569
570 pub fn with_fiscal_year(mut self, fiscal_year: i32) -> Self {
572 self.fiscal_year = fiscal_year;
573 self
574 }
575
576 pub fn with_asset_composition(mut self, composition: AssetComposition) -> Self {
578 self.asset_composition = Some(composition);
579 self
580 }
581
582 pub fn with_capital_structure(mut self, structure: CapitalStructure) -> Self {
584 self.capital_structure = Some(structure);
585 self
586 }
587
588 pub fn with_target_ratios(mut self, ratios: TargetRatios) -> Self {
590 self.target_ratios = Some(ratios);
591 self
592 }
593
594 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 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 assert!(result.is_balanced);
679
680 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 assert!(
698 tech_spec.asset_composition.intangibles_percent
699 > mfg_spec.asset_composition.intangibles_percent
700 );
701
702 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}