1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::HashMap;
10
11use datasynth_core::models::intercompany::{
12 ConsolidationJournal, ConsolidationMethod, ConsolidationStatus, EliminationEntry,
13 EliminationType, ICAggregatedBalance, ICMatchedPair, ICTransactionType, OwnershipStructure,
14};
15
16#[derive(Debug, Clone)]
18pub struct EliminationConfig {
19 pub consolidation_entity: String,
21 pub base_currency: String,
23 pub eliminate_ic_balances: bool,
25 pub eliminate_ic_revenue_expense: bool,
27 pub eliminate_unrealized_profit: bool,
29 pub eliminate_investment_equity: bool,
31 pub average_markup_rate: Decimal,
33 pub ic_inventory_percent: Decimal,
35}
36
37impl Default for EliminationConfig {
38 fn default() -> Self {
39 Self {
40 consolidation_entity: "GROUP".to_string(),
41 base_currency: "USD".to_string(),
42 eliminate_ic_balances: true,
43 eliminate_ic_revenue_expense: true,
44 eliminate_unrealized_profit: true,
45 eliminate_investment_equity: true,
46 average_markup_rate: dec!(0.05),
47 ic_inventory_percent: dec!(0.20),
48 }
49 }
50}
51
52pub struct EliminationGenerator {
54 config: EliminationConfig,
56 ownership_structure: OwnershipStructure,
58 entry_counter: u64,
60 journals: HashMap<String, ConsolidationJournal>,
62}
63
64impl EliminationGenerator {
65 pub fn new(config: EliminationConfig, ownership_structure: OwnershipStructure) -> Self {
67 Self {
68 config,
69 ownership_structure,
70 entry_counter: 0,
71 journals: HashMap::new(),
72 }
73 }
74
75 fn generate_entry_id(&mut self, elim_type: EliminationType) -> String {
77 self.entry_counter += 1;
78 let prefix = match elim_type {
79 EliminationType::ICBalances => "EB",
80 EliminationType::ICRevenueExpense => "ER",
81 EliminationType::ICProfitInInventory => "EP",
82 EliminationType::ICProfitInFixedAssets => "EA",
83 EliminationType::InvestmentEquity => "EI",
84 EliminationType::ICDividends => "ED",
85 EliminationType::ICLoans => "EL",
86 EliminationType::ICInterest => "EN",
87 EliminationType::MinorityInterest => "EM",
88 EliminationType::Goodwill => "EG",
89 EliminationType::CurrencyTranslation => "EC",
90 };
91 format!("{}{:06}", prefix, self.entry_counter)
92 }
93
94 fn get_or_create_journal(
96 &mut self,
97 fiscal_period: &str,
98 entry_date: NaiveDate,
99 ) -> &mut ConsolidationJournal {
100 self.journals
101 .entry(fiscal_period.to_string())
102 .or_insert_with(|| {
103 ConsolidationJournal::new(
104 self.config.consolidation_entity.clone(),
105 fiscal_period.to_string(),
106 entry_date,
107 )
108 })
109 }
110
111 pub fn generate_eliminations(
113 &mut self,
114 fiscal_period: &str,
115 entry_date: NaiveDate,
116 ic_balances: &[ICAggregatedBalance],
117 ic_transactions: &[ICMatchedPair],
118 investment_amounts: &HashMap<String, Decimal>,
119 equity_amounts: &HashMap<String, HashMap<String, Decimal>>,
120 ) -> &ConsolidationJournal {
121 if self.config.eliminate_ic_balances {
123 self.generate_ic_balance_eliminations(fiscal_period, entry_date, ic_balances);
124 }
125
126 if self.config.eliminate_ic_revenue_expense {
128 self.generate_ic_revenue_expense_eliminations(
129 fiscal_period,
130 entry_date,
131 ic_transactions,
132 );
133 }
134
135 if self.config.eliminate_unrealized_profit {
137 self.generate_unrealized_profit_eliminations(
138 fiscal_period,
139 entry_date,
140 ic_transactions,
141 );
142 }
143
144 if self.config.eliminate_investment_equity {
146 self.generate_investment_equity_eliminations(
147 fiscal_period,
148 entry_date,
149 investment_amounts,
150 equity_amounts,
151 );
152 }
153
154 self.get_or_create_journal(fiscal_period, entry_date)
155 }
156
157 pub fn generate_ic_balance_eliminations(
159 &mut self,
160 fiscal_period: &str,
161 entry_date: NaiveDate,
162 balances: &[ICAggregatedBalance],
163 ) {
164 for balance in balances {
165 if balance.elimination_amount() == Decimal::ZERO {
166 continue;
167 }
168
169 let entry = EliminationEntry::create_ic_balance_elimination(
170 self.generate_entry_id(EliminationType::ICBalances),
171 self.config.consolidation_entity.clone(),
172 fiscal_period.to_string(),
173 entry_date,
174 &balance.creditor_company,
175 &balance.debtor_company,
176 &balance.receivable_account,
177 &balance.payable_account,
178 balance.elimination_amount(),
179 balance.currency.clone(),
180 );
181
182 let journal = self.get_or_create_journal(fiscal_period, entry_date);
183 journal.add_entry(entry);
184 }
185 }
186
187 pub fn generate_ic_revenue_expense_eliminations(
189 &mut self,
190 fiscal_period: &str,
191 entry_date: NaiveDate,
192 transactions: &[ICMatchedPair],
193 ) {
194 let mut aggregated: HashMap<(String, String, ICTransactionType), Decimal> = HashMap::new();
196
197 for tx in transactions {
198 if tx.transaction_type.affects_pnl() {
199 let key = (
200 tx.seller_company.clone(),
201 tx.buyer_company.clone(),
202 tx.transaction_type,
203 );
204 *aggregated.entry(key).or_insert(Decimal::ZERO) += tx.amount;
205 }
206 }
207
208 for ((seller, buyer, tx_type), amount) in aggregated {
209 if amount == Decimal::ZERO {
210 continue;
211 }
212
213 let revenue_account = match tx_type {
214 ICTransactionType::GoodsSale => "4100",
215 ICTransactionType::ServiceProvided => "4200",
216 ICTransactionType::ManagementFee => "4300",
217 ICTransactionType::Royalty => "4400",
218 ICTransactionType::LoanInterest => "4500",
219 _ => "4900",
220 };
221
222 let expense_account = match tx_type {
223 ICTransactionType::GoodsSale => "5100",
224 ICTransactionType::ServiceProvided => "5200",
225 ICTransactionType::ManagementFee => "5300",
226 ICTransactionType::Royalty => "5400",
227 ICTransactionType::LoanInterest => "5500",
228 _ => "5900",
229 };
230
231 let entry = EliminationEntry::create_ic_revenue_expense_elimination(
232 self.generate_entry_id(EliminationType::ICRevenueExpense),
233 self.config.consolidation_entity.clone(),
234 fiscal_period.to_string(),
235 entry_date,
236 &seller,
237 &buyer,
238 revenue_account,
239 expense_account,
240 amount,
241 self.config.base_currency.clone(),
242 );
243
244 let journal = self.get_or_create_journal(fiscal_period, entry_date);
245 journal.add_entry(entry);
246 }
247 }
248
249 pub fn generate_unrealized_profit_eliminations(
251 &mut self,
252 fiscal_period: &str,
253 entry_date: NaiveDate,
254 transactions: &[ICMatchedPair],
255 ) {
256 let mut unrealized_by_pair: HashMap<(String, String), Decimal> = HashMap::new();
258
259 for tx in transactions {
260 if tx.transaction_type == ICTransactionType::GoodsSale {
261 let key = (tx.seller_company.clone(), tx.buyer_company.clone());
262
263 let unrealized =
265 tx.amount * self.config.average_markup_rate * self.config.ic_inventory_percent;
266
267 *unrealized_by_pair.entry(key).or_insert(Decimal::ZERO) += unrealized;
268 }
269 }
270
271 for ((seller, buyer), unrealized_profit) in unrealized_by_pair {
272 if unrealized_profit < dec!(0.01) {
273 continue;
274 }
275
276 let entry = EliminationEntry::create_unrealized_profit_elimination(
277 self.generate_entry_id(EliminationType::ICProfitInInventory),
278 self.config.consolidation_entity.clone(),
279 fiscal_period.to_string(),
280 entry_date,
281 &seller,
282 &buyer,
283 unrealized_profit.round_dp(2),
284 self.config.base_currency.clone(),
285 );
286
287 let journal = self.get_or_create_journal(fiscal_period, entry_date);
288 journal.add_entry(entry);
289 }
290 }
291
292 pub fn generate_investment_equity_eliminations(
294 &mut self,
295 fiscal_period: &str,
296 entry_date: NaiveDate,
297 investment_amounts: &HashMap<String, Decimal>,
298 equity_amounts: &HashMap<String, HashMap<String, Decimal>>,
299 ) {
300 let relationships_to_process: Vec<_> = self
302 .ownership_structure
303 .relationships
304 .iter()
305 .filter(|r| r.consolidation_method == ConsolidationMethod::Full)
306 .map(|r| {
307 (
308 r.parent_company.clone(),
309 r.subsidiary_company.clone(),
310 r.ownership_percentage,
311 )
312 })
313 .collect();
314
315 for (parent, subsidiary, ownership_pct) in relationships_to_process {
316 let investment = investment_amounts
317 .get(&format!("{}_{}", parent, subsidiary))
318 .copied()
319 .unwrap_or(Decimal::ZERO);
320
321 if investment == Decimal::ZERO {
322 continue;
323 }
324
325 let equity_components: Vec<(String, Decimal)> = equity_amounts
327 .get(&subsidiary)
328 .map(|eq| eq.iter().map(|(k, v)| (k.clone(), *v)).collect())
329 .unwrap_or_else(|| {
330 vec![
332 ("3100".to_string(), investment * dec!(0.10)), ("3200".to_string(), investment * dec!(0.30)), ("3300".to_string(), investment * dec!(0.60)), ]
336 });
337
338 let total_equity: Decimal = equity_components.iter().map(|(_, v)| v).sum();
339
340 let goodwill = if investment > total_equity {
342 Some(investment - total_equity)
343 } else {
344 None
345 };
346
347 let minority_interest = if ownership_pct < dec!(100) {
349 let minority_pct = (dec!(100) - ownership_pct) / dec!(100);
350 Some(total_equity * minority_pct)
351 } else {
352 None
353 };
354
355 let entry_id = self.generate_entry_id(EliminationType::InvestmentEquity);
356 let consolidation_entity = self.config.consolidation_entity.clone();
357 let base_currency = self.config.base_currency.clone();
358
359 let entry = EliminationEntry::create_investment_equity_elimination(
360 entry_id,
361 consolidation_entity,
362 fiscal_period.to_string(),
363 entry_date,
364 &parent,
365 &subsidiary,
366 investment,
367 equity_components,
368 goodwill,
369 minority_interest,
370 base_currency,
371 );
372
373 let journal = self.get_or_create_journal(fiscal_period, entry_date);
374 journal.add_entry(entry);
375 }
376 }
377
378 pub fn generate_dividend_elimination(
380 &mut self,
381 fiscal_period: &str,
382 entry_date: NaiveDate,
383 paying_company: &str,
384 receiving_company: &str,
385 dividend_amount: Decimal,
386 ) -> EliminationEntry {
387 let mut entry = EliminationEntry::new(
388 self.generate_entry_id(EliminationType::ICDividends),
389 EliminationType::ICDividends,
390 self.config.consolidation_entity.clone(),
391 fiscal_period.to_string(),
392 entry_date,
393 self.config.base_currency.clone(),
394 );
395
396 entry.related_companies = vec![paying_company.to_string(), receiving_company.to_string()];
397 entry.description = format!(
398 "Eliminate IC dividend from {} to {}",
399 paying_company, receiving_company
400 );
401
402 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
404 line_number: 1,
405 company: receiving_company.to_string(),
406 account: "4600".to_string(), is_debit: true,
408 amount: dividend_amount,
409 currency: self.config.base_currency.clone(),
410 description: "Eliminate dividend income".to_string(),
411 });
412
413 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
415 line_number: 2,
416 company: paying_company.to_string(),
417 account: "3300".to_string(), is_debit: false,
419 amount: dividend_amount,
420 currency: self.config.base_currency.clone(),
421 description: "Restore retained earnings".to_string(),
422 });
423
424 let journal = self.get_or_create_journal(fiscal_period, entry_date);
425 journal.add_entry(entry.clone());
426
427 entry
428 }
429
430 pub fn generate_minority_interest_allocation(
432 &mut self,
433 fiscal_period: &str,
434 entry_date: NaiveDate,
435 subsidiary: &str,
436 net_income: Decimal,
437 minority_percentage: Decimal,
438 ) -> Option<EliminationEntry> {
439 if minority_percentage <= Decimal::ZERO || minority_percentage >= dec!(100) {
440 return None;
441 }
442
443 let minority_share = net_income * minority_percentage / dec!(100);
444
445 if minority_share.abs() < dec!(0.01) {
446 return None;
447 }
448
449 let mut entry = EliminationEntry::new(
450 self.generate_entry_id(EliminationType::MinorityInterest),
451 EliminationType::MinorityInterest,
452 self.config.consolidation_entity.clone(),
453 fiscal_period.to_string(),
454 entry_date,
455 self.config.base_currency.clone(),
456 );
457
458 entry.related_companies = vec![subsidiary.to_string()];
459 entry.description = format!("Minority interest share of {} profit/loss", subsidiary);
460
461 if net_income > Decimal::ZERO {
462 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
464 line_number: 1,
465 company: self.config.consolidation_entity.clone(),
466 account: "3400".to_string(), is_debit: true,
468 amount: minority_share,
469 currency: self.config.base_currency.clone(),
470 description: "NCI share of net income".to_string(),
471 });
472
473 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
474 line_number: 2,
475 company: self.config.consolidation_entity.clone(),
476 account: "3500".to_string(), is_debit: false,
478 amount: minority_share,
479 currency: self.config.base_currency.clone(),
480 description: "Increase NCI for share of income".to_string(),
481 });
482 } else {
483 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
485 line_number: 1,
486 company: self.config.consolidation_entity.clone(),
487 account: "3500".to_string(), is_debit: true,
489 amount: minority_share.abs(),
490 currency: self.config.base_currency.clone(),
491 description: "Decrease NCI for share of loss".to_string(),
492 });
493
494 entry.add_line(datasynth_core::models::intercompany::EliminationLine {
495 line_number: 2,
496 company: self.config.consolidation_entity.clone(),
497 account: "3400".to_string(), is_debit: false,
499 amount: minority_share.abs(),
500 currency: self.config.base_currency.clone(),
501 description: "NCI share of net loss".to_string(),
502 });
503 }
504
505 let journal = self.get_or_create_journal(fiscal_period, entry_date);
506 journal.add_entry(entry.clone());
507
508 Some(entry)
509 }
510
511 pub fn get_journal(&self, fiscal_period: &str) -> Option<&ConsolidationJournal> {
513 self.journals.get(fiscal_period)
514 }
515
516 pub fn get_all_journals(&self) -> &HashMap<String, ConsolidationJournal> {
518 &self.journals
519 }
520
521 pub fn finalize_journal(
523 &mut self,
524 fiscal_period: &str,
525 approved_by: String,
526 ) -> Option<&ConsolidationJournal> {
527 if let Some(journal) = self.journals.get_mut(fiscal_period) {
528 journal.submit();
529 journal.approve(approved_by);
530 Some(journal)
531 } else {
532 None
533 }
534 }
535
536 pub fn post_journal(&mut self, fiscal_period: &str) -> Option<&ConsolidationJournal> {
538 if let Some(journal) = self.journals.get_mut(fiscal_period) {
539 journal.post();
540 Some(journal)
541 } else {
542 None
543 }
544 }
545
546 pub fn get_summary(&self, fiscal_period: &str) -> Option<EliminationSummaryReport> {
548 self.journals.get(fiscal_period).map(|journal| {
549 let mut by_type: HashMap<EliminationType, (usize, Decimal)> = HashMap::new();
550
551 for entry in &journal.entries {
552 let stats = by_type
553 .entry(entry.elimination_type)
554 .or_insert((0, Decimal::ZERO));
555 stats.0 += 1;
556 stats.1 += entry.total_debit;
557 }
558
559 EliminationSummaryReport {
560 fiscal_period: fiscal_period.to_string(),
561 consolidation_entity: journal.consolidation_entity.clone(),
562 total_entries: journal.entries.len(),
563 total_debit: journal.total_debits,
564 total_credit: journal.total_credits,
565 is_balanced: journal.is_balanced,
566 status: journal.status,
567 by_type,
568 }
569 })
570 }
571
572 pub fn reset(&mut self) {
574 self.entry_counter = 0;
575 self.journals.clear();
576 }
577}
578
579#[derive(Debug, Clone)]
581pub struct EliminationSummaryReport {
582 pub fiscal_period: String,
584 pub consolidation_entity: String,
586 pub total_entries: usize,
588 pub total_debit: Decimal,
590 pub total_credit: Decimal,
592 pub is_balanced: bool,
594 pub status: ConsolidationStatus,
596 pub by_type: HashMap<EliminationType, (usize, Decimal)>,
598}
599
600#[cfg(test)]
601#[allow(clippy::unwrap_used)]
602mod tests {
603 use super::*;
604 use chrono::NaiveDate;
605 use datasynth_core::models::intercompany::IntercompanyRelationship;
606 use rust_decimal_macros::dec;
607
608 fn create_test_ownership_structure() -> OwnershipStructure {
609 let mut structure = OwnershipStructure::new("1000".to_string());
610 structure.add_relationship(IntercompanyRelationship::new(
611 "REL001".to_string(),
612 "1000".to_string(),
613 "1100".to_string(),
614 dec!(100),
615 NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
616 ));
617 structure.add_relationship(IntercompanyRelationship::new(
618 "REL002".to_string(),
619 "1000".to_string(),
620 "1200".to_string(),
621 dec!(80),
622 NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
623 ));
624 structure
625 }
626
627 #[test]
628 fn test_elimination_generator_creation() {
629 let config = EliminationConfig::default();
630 let structure = create_test_ownership_structure();
631 let generator = EliminationGenerator::new(config, structure);
632
633 assert!(generator.journals.is_empty());
634 }
635
636 #[test]
637 fn test_generate_ic_balance_eliminations() {
638 let config = EliminationConfig::default();
639 let structure = create_test_ownership_structure();
640 let mut generator = EliminationGenerator::new(config, structure);
641
642 let balances = vec![ICAggregatedBalance {
643 creditor_company: "1000".to_string(),
644 debtor_company: "1100".to_string(),
645 receivable_account: "1310".to_string(),
646 payable_account: "2110".to_string(),
647 receivable_balance: dec!(50000),
648 payable_balance: dec!(50000),
649 difference: Decimal::ZERO,
650 currency: "USD".to_string(),
651 as_of_date: NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
652 is_matched: true,
653 }];
654
655 generator.generate_ic_balance_eliminations(
656 "202206",
657 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
658 &balances,
659 );
660
661 let journal = generator.get_journal("202206").unwrap();
662 assert_eq!(journal.entries.len(), 1);
663 assert!(journal.is_balanced);
664 }
665
666 #[test]
667 fn test_generate_ic_revenue_expense_eliminations() {
668 let config = EliminationConfig::default();
669 let structure = create_test_ownership_structure();
670 let mut generator = EliminationGenerator::new(config, structure);
671
672 let transactions = vec![ICMatchedPair::new(
673 "IC001".to_string(),
674 ICTransactionType::ServiceProvided,
675 "1000".to_string(),
676 "1100".to_string(),
677 dec!(25000),
678 "USD".to_string(),
679 NaiveDate::from_ymd_opt(2022, 6, 15).unwrap(),
680 )];
681
682 generator.generate_ic_revenue_expense_eliminations(
683 "202206",
684 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
685 &transactions,
686 );
687
688 let journal = generator.get_journal("202206").unwrap();
689 assert_eq!(journal.entries.len(), 1);
690 assert!(journal.is_balanced);
691 }
692
693 #[test]
694 fn test_generate_unrealized_profit_eliminations() {
695 let config = EliminationConfig::default();
696 let structure = create_test_ownership_structure();
697 let mut generator = EliminationGenerator::new(config, structure);
698
699 let transactions = vec![ICMatchedPair::new(
700 "IC001".to_string(),
701 ICTransactionType::GoodsSale,
702 "1000".to_string(),
703 "1100".to_string(),
704 dec!(100000),
705 "USD".to_string(),
706 NaiveDate::from_ymd_opt(2022, 6, 15).unwrap(),
707 )];
708
709 generator.generate_unrealized_profit_eliminations(
710 "202206",
711 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
712 &transactions,
713 );
714
715 let journal = generator.get_journal("202206").unwrap();
716 assert_eq!(journal.entries.len(), 1);
717 assert!(journal.is_balanced);
719 }
720
721 #[test]
722 fn test_generate_dividend_elimination() {
723 let config = EliminationConfig::default();
724 let structure = create_test_ownership_structure();
725 let mut generator = EliminationGenerator::new(config, structure);
726
727 let entry = generator.generate_dividend_elimination(
728 "202206",
729 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
730 "1100",
731 "1000",
732 dec!(50000),
733 );
734
735 assert!(entry.is_balanced());
736 assert_eq!(entry.elimination_type, EliminationType::ICDividends);
737 }
738
739 #[test]
740 fn test_generate_minority_interest_allocation() {
741 let config = EliminationConfig::default();
742 let structure = create_test_ownership_structure();
743 let mut generator = EliminationGenerator::new(config, structure);
744
745 let entry = generator.generate_minority_interest_allocation(
746 "202206",
747 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
748 "1200",
749 dec!(100000),
750 dec!(20), );
752
753 assert!(entry.is_some());
754 let entry = entry.unwrap();
755 assert!(entry.is_balanced());
756 assert_eq!(entry.total_debit, dec!(20000));
758 }
759
760 #[test]
761 fn test_finalize_and_post_journal() {
762 let config = EliminationConfig::default();
763 let structure = create_test_ownership_structure();
764 let mut generator = EliminationGenerator::new(config, structure);
765
766 let balances = vec![ICAggregatedBalance {
767 creditor_company: "1000".to_string(),
768 debtor_company: "1100".to_string(),
769 receivable_account: "1310".to_string(),
770 payable_account: "2110".to_string(),
771 receivable_balance: dec!(50000),
772 payable_balance: dec!(50000),
773 difference: Decimal::ZERO,
774 currency: "USD".to_string(),
775 as_of_date: NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
776 is_matched: true,
777 }];
778
779 generator.generate_ic_balance_eliminations(
780 "202206",
781 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
782 &balances,
783 );
784
785 generator.finalize_journal("202206", "ADMIN".to_string());
786 let journal = generator.get_journal("202206").unwrap();
787 assert_eq!(journal.status, ConsolidationStatus::Approved);
788
789 generator.post_journal("202206");
790 let journal = generator.get_journal("202206").unwrap();
791 assert_eq!(journal.status, ConsolidationStatus::Posted);
792 }
793}