1use 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#[derive(Debug, Clone)]
25pub struct OpeningBalanceConfig {
26 pub total_assets: Decimal,
28 pub industry: IndustryType,
30 pub asset_composition: Option<AssetComposition>,
32 pub capital_structure: Option<CapitalStructure>,
34 pub target_ratios: Option<TargetRatios>,
36 pub add_variation: bool,
38 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
56pub struct OpeningBalanceGenerator {
58 config: OpeningBalanceConfig,
59 rng: ChaCha8Rng,
60}
61
62impl OpeningBalanceGenerator {
63 pub fn new(config: OpeningBalanceConfig, rng: ChaCha8Rng) -> Self {
65 Self { config, rng }
66 }
67
68 pub fn with_seed(config: OpeningBalanceConfig, seed: u64) -> Self {
70 Self::new(config, seeded_rng(seed, 0))
71 }
72
73 pub fn with_defaults(rng: ChaCha8Rng) -> Self {
75 Self::new(OpeningBalanceConfig::default(), rng)
76 }
77
78 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 let asset_comp = spec.asset_composition.clone();
91 let capital_struct = spec.capital_structure.clone();
92
93 let total_assets = spec.total_assets;
95
96 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 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 let ppe_gross = self.apply_variation(fixed_assets * dec!(1.4)); let accumulated_depreciation = ppe_gross - fixed_assets;
117
118 let total_liabilities = total_assets * capital_struct.debt_percent / dec!(100);
120 let total_equity = total_assets - total_liabilities;
121
122 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 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 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 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 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 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 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 let simple_balances: HashMap<String, Decimal> = balances
316 .iter()
317 .map(|(k, v)| (k.clone(), v.closing_balance))
318 .collect();
319
320 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 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 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 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 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 fn calculate_ar_from_dso(
394 &self,
395 target_ratios: &TargetRatios,
396 max_ar: Decimal,
397 _as_of_date: NaiveDate,
398 ) -> Decimal {
399 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 target_ar.min(max_ar * dec!(0.7))
408 }
409
410 fn calculate_ap_from_dpo(
412 &self,
413 target_ratios: &TargetRatios,
414 current_liabilities: Decimal,
415 _as_of_date: NaiveDate,
416 ) -> Decimal {
417 let estimated_cogs = current_liabilities * dec!(8);
421 let target_ap = (Decimal::from(target_ratios.target_dpo_days) * estimated_cogs) / dec!(365);
422
423 target_ap.min(current_liabilities * dec!(0.5))
425 }
426
427 fn find_account(
429 &self,
430 chart_of_accounts: &ChartOfAccounts,
431 default_code: &str,
432 description: &str,
433 ) -> String {
434 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 default_code.to_string()
447 }
448
449 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 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 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 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 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 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 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 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
551pub 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 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 pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
590 self.currency = currency.into();
591 self
592 }
593
594 pub fn with_fiscal_year(mut self, fiscal_year: i32) -> Self {
596 self.fiscal_year = fiscal_year;
597 self
598 }
599
600 pub fn with_asset_composition(mut self, composition: AssetComposition) -> Self {
602 self.asset_composition = Some(composition);
603 self
604 }
605
606 pub fn with_capital_structure(mut self, structure: CapitalStructure) -> Self {
608 self.capital_structure = Some(structure);
609 self
610 }
611
612 pub fn with_target_ratios(mut self, ratios: TargetRatios) -> Self {
614 self.target_ratios = Some(ratios);
615 self
616 }
617
618 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 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 assert!(result.is_balanced);
704
705 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 assert!(
723 tech_spec.asset_composition.intangibles_percent
724 > mfg_spec.asset_composition.intangibles_percent
725 );
726
727 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}