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.journals.get(fiscal_period).unwrap()
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)]
601mod tests {
602 use super::*;
603 use chrono::NaiveDate;
604 use datasynth_core::models::intercompany::IntercompanyRelationship;
605 use rust_decimal_macros::dec;
606
607 fn create_test_ownership_structure() -> OwnershipStructure {
608 let mut structure = OwnershipStructure::new("1000".to_string());
609 structure.add_relationship(IntercompanyRelationship::new(
610 "REL001".to_string(),
611 "1000".to_string(),
612 "1100".to_string(),
613 dec!(100),
614 NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
615 ));
616 structure.add_relationship(IntercompanyRelationship::new(
617 "REL002".to_string(),
618 "1000".to_string(),
619 "1200".to_string(),
620 dec!(80),
621 NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
622 ));
623 structure
624 }
625
626 #[test]
627 fn test_elimination_generator_creation() {
628 let config = EliminationConfig::default();
629 let structure = create_test_ownership_structure();
630 let generator = EliminationGenerator::new(config, structure);
631
632 assert!(generator.journals.is_empty());
633 }
634
635 #[test]
636 fn test_generate_ic_balance_eliminations() {
637 let config = EliminationConfig::default();
638 let structure = create_test_ownership_structure();
639 let mut generator = EliminationGenerator::new(config, structure);
640
641 let balances = vec![ICAggregatedBalance {
642 creditor_company: "1000".to_string(),
643 debtor_company: "1100".to_string(),
644 receivable_account: "1310".to_string(),
645 payable_account: "2110".to_string(),
646 receivable_balance: dec!(50000),
647 payable_balance: dec!(50000),
648 difference: Decimal::ZERO,
649 currency: "USD".to_string(),
650 as_of_date: NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
651 is_matched: true,
652 }];
653
654 generator.generate_ic_balance_eliminations(
655 "202206",
656 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
657 &balances,
658 );
659
660 let journal = generator.get_journal("202206").unwrap();
661 assert_eq!(journal.entries.len(), 1);
662 assert!(journal.is_balanced);
663 }
664
665 #[test]
666 fn test_generate_ic_revenue_expense_eliminations() {
667 let config = EliminationConfig::default();
668 let structure = create_test_ownership_structure();
669 let mut generator = EliminationGenerator::new(config, structure);
670
671 let transactions = vec![ICMatchedPair::new(
672 "IC001".to_string(),
673 ICTransactionType::ServiceProvided,
674 "1000".to_string(),
675 "1100".to_string(),
676 dec!(25000),
677 "USD".to_string(),
678 NaiveDate::from_ymd_opt(2022, 6, 15).unwrap(),
679 )];
680
681 generator.generate_ic_revenue_expense_eliminations(
682 "202206",
683 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
684 &transactions,
685 );
686
687 let journal = generator.get_journal("202206").unwrap();
688 assert_eq!(journal.entries.len(), 1);
689 assert!(journal.is_balanced);
690 }
691
692 #[test]
693 fn test_generate_unrealized_profit_eliminations() {
694 let config = EliminationConfig::default();
695 let structure = create_test_ownership_structure();
696 let mut generator = EliminationGenerator::new(config, structure);
697
698 let transactions = vec![ICMatchedPair::new(
699 "IC001".to_string(),
700 ICTransactionType::GoodsSale,
701 "1000".to_string(),
702 "1100".to_string(),
703 dec!(100000),
704 "USD".to_string(),
705 NaiveDate::from_ymd_opt(2022, 6, 15).unwrap(),
706 )];
707
708 generator.generate_unrealized_profit_eliminations(
709 "202206",
710 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
711 &transactions,
712 );
713
714 let journal = generator.get_journal("202206").unwrap();
715 assert_eq!(journal.entries.len(), 1);
716 assert!(journal.is_balanced);
718 }
719
720 #[test]
721 fn test_generate_dividend_elimination() {
722 let config = EliminationConfig::default();
723 let structure = create_test_ownership_structure();
724 let mut generator = EliminationGenerator::new(config, structure);
725
726 let entry = generator.generate_dividend_elimination(
727 "202206",
728 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
729 "1100",
730 "1000",
731 dec!(50000),
732 );
733
734 assert!(entry.is_balanced());
735 assert_eq!(entry.elimination_type, EliminationType::ICDividends);
736 }
737
738 #[test]
739 fn test_generate_minority_interest_allocation() {
740 let config = EliminationConfig::default();
741 let structure = create_test_ownership_structure();
742 let mut generator = EliminationGenerator::new(config, structure);
743
744 let entry = generator.generate_minority_interest_allocation(
745 "202206",
746 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
747 "1200",
748 dec!(100000),
749 dec!(20), );
751
752 assert!(entry.is_some());
753 let entry = entry.unwrap();
754 assert!(entry.is_balanced());
755 assert_eq!(entry.total_debit, dec!(20000));
757 }
758
759 #[test]
760 fn test_finalize_and_post_journal() {
761 let config = EliminationConfig::default();
762 let structure = create_test_ownership_structure();
763 let mut generator = EliminationGenerator::new(config, structure);
764
765 let balances = vec![ICAggregatedBalance {
766 creditor_company: "1000".to_string(),
767 debtor_company: "1100".to_string(),
768 receivable_account: "1310".to_string(),
769 payable_account: "2110".to_string(),
770 receivable_balance: dec!(50000),
771 payable_balance: dec!(50000),
772 difference: Decimal::ZERO,
773 currency: "USD".to_string(),
774 as_of_date: NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
775 is_matched: true,
776 }];
777
778 generator.generate_ic_balance_eliminations(
779 "202206",
780 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
781 &balances,
782 );
783
784 generator.finalize_journal("202206", "ADMIN".to_string());
785 let journal = generator.get_journal("202206").unwrap();
786 assert_eq!(journal.status, ConsolidationStatus::Approved);
787
788 generator.post_journal("202206");
789 let journal = generator.get_journal("202206").unwrap();
790 assert_eq!(journal.status, ConsolidationStatus::Posted);
791 }
792}