1use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EliminationType {
12 ICBalances,
14 ICRevenueExpense,
16 ICProfitInInventory,
18 ICProfitInFixedAssets,
20 InvestmentEquity,
22 ICDividends,
24 ICLoans,
26 ICInterest,
28 MinorityInterest,
30 Goodwill,
32 CurrencyTranslation,
34}
35
36impl EliminationType {
37 pub fn description(&self) -> &'static str {
39 match self {
40 Self::ICBalances => "Eliminate intercompany receivables and payables",
41 Self::ICRevenueExpense => "Eliminate intercompany revenue and expense",
42 Self::ICProfitInInventory => "Eliminate unrealized profit in inventory",
43 Self::ICProfitInFixedAssets => "Eliminate unrealized profit in fixed assets",
44 Self::InvestmentEquity => "Eliminate investment against subsidiary equity",
45 Self::ICDividends => "Eliminate intercompany dividends",
46 Self::ICLoans => "Eliminate intercompany loan balances",
47 Self::ICInterest => "Eliminate intercompany interest income/expense",
48 Self::MinorityInterest => "Recognize non-controlling interest",
49 Self::Goodwill => "Recognize goodwill from acquisition",
50 Self::CurrencyTranslation => "Currency translation adjustment",
51 }
52 }
53
54 pub fn affects_pnl(&self) -> bool {
56 matches!(
57 self,
58 Self::ICRevenueExpense
59 | Self::ICProfitInInventory
60 | Self::ICProfitInFixedAssets
61 | Self::ICDividends
62 | Self::ICInterest
63 )
64 }
65
66 pub fn is_recurring(&self) -> bool {
68 matches!(
69 self,
70 Self::ICBalances
71 | Self::ICRevenueExpense
72 | Self::ICLoans
73 | Self::ICInterest
74 | Self::MinorityInterest
75 )
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EliminationEntry {
82 pub entry_id: String,
84 pub elimination_type: EliminationType,
86 pub consolidation_entity: String,
88 pub fiscal_period: String,
90 pub entry_date: NaiveDate,
92 pub related_companies: Vec<String>,
94 pub lines: Vec<EliminationLine>,
96 pub total_debit: Decimal,
98 pub total_credit: Decimal,
100 pub currency: String,
102 pub is_permanent: bool,
104 pub ic_references: Vec<String>,
106 pub description: String,
108 pub created_by: String,
110 pub created_at: chrono::NaiveDateTime,
112}
113
114impl EliminationEntry {
115 pub fn new(
117 entry_id: String,
118 elimination_type: EliminationType,
119 consolidation_entity: String,
120 fiscal_period: String,
121 entry_date: NaiveDate,
122 currency: String,
123 ) -> Self {
124 Self {
125 entry_id,
126 elimination_type,
127 consolidation_entity,
128 fiscal_period,
129 entry_date,
130 related_companies: Vec::new(),
131 lines: Vec::new(),
132 total_debit: Decimal::ZERO,
133 total_credit: Decimal::ZERO,
134 currency,
135 is_permanent: !elimination_type.is_recurring(),
136 ic_references: Vec::new(),
137 description: elimination_type.description().to_string(),
138 created_by: "SYSTEM".to_string(),
139 created_at: chrono::Utc::now().naive_utc(),
140 }
141 }
142
143 pub fn add_line(&mut self, line: EliminationLine) {
145 if line.is_debit {
146 self.total_debit += line.amount;
147 } else {
148 self.total_credit += line.amount;
149 }
150 self.lines.push(line);
151 }
152
153 pub fn is_balanced(&self) -> bool {
155 self.total_debit == self.total_credit
156 }
157
158 #[allow(clippy::too_many_arguments)]
160 pub fn create_ic_balance_elimination(
161 entry_id: String,
162 consolidation_entity: String,
163 fiscal_period: String,
164 entry_date: NaiveDate,
165 company1: &str,
166 company2: &str,
167 receivable_account: &str,
168 payable_account: &str,
169 amount: Decimal,
170 currency: String,
171 ) -> Self {
172 let mut entry = Self::new(
173 entry_id,
174 EliminationType::ICBalances,
175 consolidation_entity,
176 fiscal_period,
177 entry_date,
178 currency.clone(),
179 );
180
181 entry.related_companies = vec![company1.to_string(), company2.to_string()];
182 entry.description = format!("Eliminate IC balance between {} and {}", company1, company2);
183
184 entry.add_line(EliminationLine {
186 line_number: 1,
187 company: company2.to_string(),
188 account: payable_account.to_string(),
189 is_debit: true,
190 amount,
191 currency: currency.clone(),
192 description: format!("Eliminate IC payable to {}", company1),
193 });
194
195 entry.add_line(EliminationLine {
197 line_number: 2,
198 company: company1.to_string(),
199 account: receivable_account.to_string(),
200 is_debit: false,
201 amount,
202 currency,
203 description: format!("Eliminate IC receivable from {}", company2),
204 });
205
206 entry
207 }
208
209 #[allow(clippy::too_many_arguments)]
211 pub fn create_ic_revenue_expense_elimination(
212 entry_id: String,
213 consolidation_entity: String,
214 fiscal_period: String,
215 entry_date: NaiveDate,
216 seller: &str,
217 buyer: &str,
218 revenue_account: &str,
219 expense_account: &str,
220 amount: Decimal,
221 currency: String,
222 ) -> Self {
223 let mut entry = Self::new(
224 entry_id,
225 EliminationType::ICRevenueExpense,
226 consolidation_entity,
227 fiscal_period,
228 entry_date,
229 currency.clone(),
230 );
231
232 entry.related_companies = vec![seller.to_string(), buyer.to_string()];
233 entry.description = format!(
234 "Eliminate IC revenue/expense between {} and {}",
235 seller, buyer
236 );
237
238 entry.add_line(EliminationLine {
240 line_number: 1,
241 company: seller.to_string(),
242 account: revenue_account.to_string(),
243 is_debit: true,
244 amount,
245 currency: currency.clone(),
246 description: format!("Eliminate IC revenue from {}", buyer),
247 });
248
249 entry.add_line(EliminationLine {
251 line_number: 2,
252 company: buyer.to_string(),
253 account: expense_account.to_string(),
254 is_debit: false,
255 amount,
256 currency,
257 description: format!("Eliminate IC expense to {}", seller),
258 });
259
260 entry
261 }
262
263 #[allow(clippy::too_many_arguments)]
265 pub fn create_unrealized_profit_elimination(
266 entry_id: String,
267 consolidation_entity: String,
268 fiscal_period: String,
269 entry_date: NaiveDate,
270 seller: &str,
271 buyer: &str,
272 unrealized_profit: Decimal,
273 currency: String,
274 ) -> Self {
275 let mut entry = Self::new(
276 entry_id,
277 EliminationType::ICProfitInInventory,
278 consolidation_entity,
279 fiscal_period,
280 entry_date,
281 currency.clone(),
282 );
283
284 entry.related_companies = vec![seller.to_string(), buyer.to_string()];
285 entry.description = format!(
286 "Eliminate unrealized profit in inventory from {} to {}",
287 seller, buyer
288 );
289
290 entry.add_line(EliminationLine {
292 line_number: 1,
293 company: seller.to_string(),
294 account: "5000".to_string(), is_debit: true,
296 amount: unrealized_profit,
297 currency: currency.clone(),
298 description: "Eliminate unrealized profit".to_string(),
299 });
300
301 entry.add_line(EliminationLine {
303 line_number: 2,
304 company: buyer.to_string(),
305 account: "1400".to_string(), is_debit: false,
307 amount: unrealized_profit,
308 currency,
309 description: "Reduce inventory to cost".to_string(),
310 });
311
312 entry
313 }
314
315 #[allow(clippy::too_many_arguments)]
317 pub fn create_investment_equity_elimination(
318 entry_id: String,
319 consolidation_entity: String,
320 fiscal_period: String,
321 entry_date: NaiveDate,
322 parent: &str,
323 subsidiary: &str,
324 investment_amount: Decimal,
325 equity_components: Vec<(String, Decimal)>, goodwill: Option<Decimal>,
327 minority_interest: Option<Decimal>,
328 currency: String,
329 ) -> Self {
330 let consol_entity = consolidation_entity.clone();
331 let mut entry = Self::new(
332 entry_id,
333 EliminationType::InvestmentEquity,
334 consolidation_entity,
335 fiscal_period,
336 entry_date,
337 currency.clone(),
338 );
339
340 entry.related_companies = vec![parent.to_string(), subsidiary.to_string()];
341 entry.is_permanent = true;
342 entry.description = format!("Eliminate investment in {} against equity", subsidiary);
343
344 let mut line_number = 1;
345
346 for (account, amount) in equity_components {
348 entry.add_line(EliminationLine {
349 line_number,
350 company: subsidiary.to_string(),
351 account,
352 is_debit: true,
353 amount,
354 currency: currency.clone(),
355 description: "Eliminate subsidiary equity".to_string(),
356 });
357 line_number += 1;
358 }
359
360 if let Some(goodwill_amount) = goodwill {
362 entry.add_line(EliminationLine {
363 line_number,
364 company: consol_entity.clone(),
365 account: "1800".to_string(), is_debit: true,
367 amount: goodwill_amount,
368 currency: currency.clone(),
369 description: "Recognize goodwill".to_string(),
370 });
371 line_number += 1;
372 }
373
374 entry.add_line(EliminationLine {
376 line_number,
377 company: parent.to_string(),
378 account: "1510".to_string(), is_debit: false,
380 amount: investment_amount,
381 currency: currency.clone(),
382 description: "Eliminate investment in subsidiary".to_string(),
383 });
384 line_number += 1;
385
386 if let Some(mi_amount) = minority_interest {
388 entry.add_line(EliminationLine {
389 line_number,
390 company: consol_entity.clone(),
391 account: "3500".to_string(), is_debit: false,
393 amount: mi_amount,
394 currency,
395 description: "Recognize non-controlling interest".to_string(),
396 });
397 }
398
399 entry
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct EliminationLine {
406 pub line_number: u32,
408 pub company: String,
410 pub account: String,
412 pub is_debit: bool,
414 pub amount: Decimal,
416 pub currency: String,
418 pub description: String,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct EliminationRule {
425 pub rule_id: String,
427 pub name: String,
429 pub elimination_type: EliminationType,
431 pub source_account_pattern: String,
433 pub target_account_pattern: String,
435 pub company_pairs: Vec<(String, String)>,
437 pub priority: u32,
439 pub is_active: bool,
441 pub effective_date: NaiveDate,
443 pub end_date: Option<NaiveDate>,
445 pub auto_generate: bool,
447}
448
449impl EliminationRule {
450 pub fn new_ic_balance_rule(
452 rule_id: String,
453 name: String,
454 receivable_pattern: String,
455 payable_pattern: String,
456 effective_date: NaiveDate,
457 ) -> Self {
458 Self {
459 rule_id,
460 name,
461 elimination_type: EliminationType::ICBalances,
462 source_account_pattern: receivable_pattern,
463 target_account_pattern: payable_pattern,
464 company_pairs: Vec::new(),
465 priority: 10,
466 is_active: true,
467 effective_date,
468 end_date: None,
469 auto_generate: true,
470 }
471 }
472
473 pub fn is_active_on(&self, date: NaiveDate) -> bool {
475 self.is_active && date >= self.effective_date && self.end_date.is_none_or(|end| date <= end)
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ICAggregatedBalance {
482 pub creditor_company: String,
484 pub debtor_company: String,
486 pub receivable_account: String,
488 pub payable_account: String,
490 pub receivable_balance: Decimal,
492 pub payable_balance: Decimal,
494 pub difference: Decimal,
496 pub currency: String,
498 pub as_of_date: NaiveDate,
500 pub is_matched: bool,
502}
503
504impl ICAggregatedBalance {
505 pub fn new(
507 creditor_company: String,
508 debtor_company: String,
509 receivable_account: String,
510 payable_account: String,
511 currency: String,
512 as_of_date: NaiveDate,
513 ) -> Self {
514 Self {
515 creditor_company,
516 debtor_company,
517 receivable_account,
518 payable_account,
519 receivable_balance: Decimal::ZERO,
520 payable_balance: Decimal::ZERO,
521 difference: Decimal::ZERO,
522 currency,
523 as_of_date,
524 is_matched: true,
525 }
526 }
527
528 pub fn set_balances(&mut self, receivable: Decimal, payable: Decimal) {
530 self.receivable_balance = receivable;
531 self.payable_balance = payable;
532 self.difference = receivable - payable;
533 self.is_matched = self.difference == Decimal::ZERO;
534 }
535
536 pub fn elimination_amount(&self) -> Decimal {
538 self.receivable_balance.min(self.payable_balance)
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct ConsolidationJournal {
545 pub consolidation_entity: String,
547 pub fiscal_period: String,
549 pub status: ConsolidationStatus,
551 pub entries: Vec<EliminationEntry>,
553 pub summary: HashMap<EliminationType, EliminationSummary>,
555 pub total_debits: Decimal,
557 pub total_credits: Decimal,
559 pub is_balanced: bool,
561 pub created_date: NaiveDate,
563 pub modified_date: NaiveDate,
565 pub approved_by: Option<String>,
567 pub approved_date: Option<NaiveDate>,
569}
570
571impl ConsolidationJournal {
572 pub fn new(
574 consolidation_entity: String,
575 fiscal_period: String,
576 created_date: NaiveDate,
577 ) -> Self {
578 Self {
579 consolidation_entity,
580 fiscal_period,
581 status: ConsolidationStatus::Draft,
582 entries: Vec::new(),
583 summary: HashMap::new(),
584 total_debits: Decimal::ZERO,
585 total_credits: Decimal::ZERO,
586 is_balanced: true,
587 created_date,
588 modified_date: created_date,
589 approved_by: None,
590 approved_date: None,
591 }
592 }
593
594 pub fn add_entry(&mut self, entry: EliminationEntry) {
596 self.total_debits += entry.total_debit;
597 self.total_credits += entry.total_credit;
598 self.is_balanced = self.total_debits == self.total_credits;
599
600 let summary = self
602 .summary
603 .entry(entry.elimination_type)
604 .or_insert_with(|| EliminationSummary {
605 elimination_type: entry.elimination_type,
606 entry_count: 0,
607 total_amount: Decimal::ZERO,
608 });
609 summary.entry_count += 1;
610 summary.total_amount += entry.total_debit;
611
612 self.entries.push(entry);
613 self.modified_date = chrono::Utc::now().date_naive();
614 }
615
616 pub fn submit(&mut self) {
618 if self.is_balanced {
619 self.status = ConsolidationStatus::PendingApproval;
620 }
621 }
622
623 pub fn approve(&mut self, approved_by: String) {
625 self.status = ConsolidationStatus::Approved;
626 self.approved_by = Some(approved_by);
627 self.approved_date = Some(chrono::Utc::now().date_naive());
628 }
629
630 pub fn post(&mut self) {
632 if self.status == ConsolidationStatus::Approved {
633 self.status = ConsolidationStatus::Posted;
634 }
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
640#[serde(rename_all = "snake_case")]
641pub enum ConsolidationStatus {
642 #[default]
644 Draft,
645 PendingApproval,
647 Approved,
649 Posted,
651 Reversed,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct EliminationSummary {
658 pub elimination_type: EliminationType,
660 pub entry_count: usize,
662 pub total_amount: Decimal,
664}
665
666#[cfg(test)]
667#[allow(clippy::unwrap_used)]
668mod tests {
669 use super::*;
670 use rust_decimal_macros::dec;
671
672 #[test]
673 fn test_elimination_type_properties() {
674 assert!(EliminationType::ICRevenueExpense.affects_pnl());
675 assert!(!EliminationType::ICBalances.affects_pnl());
676
677 assert!(EliminationType::ICBalances.is_recurring());
678 assert!(!EliminationType::InvestmentEquity.is_recurring());
679 }
680
681 #[test]
682 fn test_ic_balance_elimination() {
683 let entry = EliminationEntry::create_ic_balance_elimination(
684 "ELIM001".to_string(),
685 "GROUP".to_string(),
686 "202206".to_string(),
687 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
688 "1000",
689 "1100",
690 "1310",
691 "2110",
692 dec!(50000),
693 "USD".to_string(),
694 );
695
696 assert_eq!(entry.lines.len(), 2);
697 assert!(entry.is_balanced());
698 assert_eq!(entry.total_debit, dec!(50000));
699 assert_eq!(entry.total_credit, dec!(50000));
700 }
701
702 #[test]
703 fn test_ic_revenue_expense_elimination() {
704 let entry = EliminationEntry::create_ic_revenue_expense_elimination(
705 "ELIM002".to_string(),
706 "GROUP".to_string(),
707 "202206".to_string(),
708 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
709 "1000",
710 "1100",
711 "4100",
712 "5100",
713 dec!(100000),
714 "USD".to_string(),
715 );
716
717 assert!(entry.is_balanced());
718 assert!(entry.elimination_type.affects_pnl());
719 }
720
721 #[test]
722 fn test_aggregated_balance() {
723 let mut balance = ICAggregatedBalance::new(
724 "1000".to_string(),
725 "1100".to_string(),
726 "1310".to_string(),
727 "2110".to_string(),
728 "USD".to_string(),
729 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
730 );
731
732 balance.set_balances(dec!(50000), dec!(50000));
733 assert!(balance.is_matched);
734 assert_eq!(balance.elimination_amount(), dec!(50000));
735
736 balance.set_balances(dec!(50000), dec!(48000));
737 assert!(!balance.is_matched);
738 assert_eq!(balance.difference, dec!(2000));
739 assert_eq!(balance.elimination_amount(), dec!(48000));
740 }
741
742 #[test]
743 fn test_consolidation_journal() {
744 let mut journal = ConsolidationJournal::new(
745 "GROUP".to_string(),
746 "202206".to_string(),
747 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
748 );
749
750 let entry = EliminationEntry::create_ic_balance_elimination(
751 "ELIM001".to_string(),
752 "GROUP".to_string(),
753 "202206".to_string(),
754 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
755 "1000",
756 "1100",
757 "1310",
758 "2110",
759 dec!(50000),
760 "USD".to_string(),
761 );
762
763 journal.add_entry(entry);
764
765 assert_eq!(journal.entries.len(), 1);
766 assert!(journal.is_balanced);
767 assert_eq!(journal.status, ConsolidationStatus::Draft);
768
769 journal.submit();
770 assert_eq!(journal.status, ConsolidationStatus::PendingApproval);
771 }
772}