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 {company1} and {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!("Eliminate IC revenue/expense between {seller} and {buyer}");
234
235 entry.add_line(EliminationLine {
237 line_number: 1,
238 company: seller.to_string(),
239 account: revenue_account.to_string(),
240 is_debit: true,
241 amount,
242 currency: currency.clone(),
243 description: format!("Eliminate IC revenue from {buyer}"),
244 });
245
246 entry.add_line(EliminationLine {
248 line_number: 2,
249 company: buyer.to_string(),
250 account: expense_account.to_string(),
251 is_debit: false,
252 amount,
253 currency,
254 description: format!("Eliminate IC expense to {seller}"),
255 });
256
257 entry
258 }
259
260 #[allow(clippy::too_many_arguments)]
262 pub fn create_unrealized_profit_elimination(
263 entry_id: String,
264 consolidation_entity: String,
265 fiscal_period: String,
266 entry_date: NaiveDate,
267 seller: &str,
268 buyer: &str,
269 unrealized_profit: Decimal,
270 currency: String,
271 ) -> Self {
272 let mut entry = Self::new(
273 entry_id,
274 EliminationType::ICProfitInInventory,
275 consolidation_entity,
276 fiscal_period,
277 entry_date,
278 currency.clone(),
279 );
280
281 entry.related_companies = vec![seller.to_string(), buyer.to_string()];
282 entry.description =
283 format!("Eliminate unrealized profit in inventory from {seller} to {buyer}");
284
285 entry.add_line(EliminationLine {
287 line_number: 1,
288 company: seller.to_string(),
289 account: "5000".to_string(), is_debit: true,
291 amount: unrealized_profit,
292 currency: currency.clone(),
293 description: "Eliminate unrealized profit".to_string(),
294 });
295
296 entry.add_line(EliminationLine {
298 line_number: 2,
299 company: buyer.to_string(),
300 account: "1400".to_string(), is_debit: false,
302 amount: unrealized_profit,
303 currency,
304 description: "Reduce inventory to cost".to_string(),
305 });
306
307 entry
308 }
309
310 #[allow(clippy::too_many_arguments)]
312 pub fn create_investment_equity_elimination(
313 entry_id: String,
314 consolidation_entity: String,
315 fiscal_period: String,
316 entry_date: NaiveDate,
317 parent: &str,
318 subsidiary: &str,
319 investment_amount: Decimal,
320 equity_components: Vec<(String, Decimal)>, goodwill: Option<Decimal>,
322 minority_interest: Option<Decimal>,
323 currency: String,
324 ) -> Self {
325 let consol_entity = consolidation_entity.clone();
326 let mut entry = Self::new(
327 entry_id,
328 EliminationType::InvestmentEquity,
329 consolidation_entity,
330 fiscal_period,
331 entry_date,
332 currency.clone(),
333 );
334
335 entry.related_companies = vec![parent.to_string(), subsidiary.to_string()];
336 entry.is_permanent = true;
337 entry.description = format!("Eliminate investment in {subsidiary} against equity");
338
339 let mut line_number = 1;
340
341 for (account, amount) in equity_components {
343 entry.add_line(EliminationLine {
344 line_number,
345 company: subsidiary.to_string(),
346 account,
347 is_debit: true,
348 amount,
349 currency: currency.clone(),
350 description: "Eliminate subsidiary equity".to_string(),
351 });
352 line_number += 1;
353 }
354
355 if let Some(goodwill_amount) = goodwill {
357 entry.add_line(EliminationLine {
358 line_number,
359 company: consol_entity.clone(),
360 account: "1800".to_string(), is_debit: true,
362 amount: goodwill_amount,
363 currency: currency.clone(),
364 description: "Recognize goodwill".to_string(),
365 });
366 line_number += 1;
367 }
368
369 entry.add_line(EliminationLine {
371 line_number,
372 company: parent.to_string(),
373 account: "1510".to_string(), is_debit: false,
375 amount: investment_amount,
376 currency: currency.clone(),
377 description: "Eliminate investment in subsidiary".to_string(),
378 });
379 line_number += 1;
380
381 if let Some(mi_amount) = minority_interest {
383 entry.add_line(EliminationLine {
384 line_number,
385 company: consol_entity.clone(),
386 account: "3500".to_string(), is_debit: false,
388 amount: mi_amount,
389 currency,
390 description: "Recognize non-controlling interest".to_string(),
391 });
392 }
393
394 entry
395 }
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct EliminationLine {
401 pub line_number: u32,
403 pub company: String,
405 pub account: String,
407 pub is_debit: bool,
409 pub amount: Decimal,
411 pub currency: String,
413 pub description: String,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct EliminationRule {
420 pub rule_id: String,
422 pub name: String,
424 pub elimination_type: EliminationType,
426 pub source_account_pattern: String,
428 pub target_account_pattern: String,
430 pub company_pairs: Vec<(String, String)>,
432 pub priority: u32,
434 pub is_active: bool,
436 pub effective_date: NaiveDate,
438 pub end_date: Option<NaiveDate>,
440 pub auto_generate: bool,
442}
443
444impl EliminationRule {
445 pub fn new_ic_balance_rule(
447 rule_id: String,
448 name: String,
449 receivable_pattern: String,
450 payable_pattern: String,
451 effective_date: NaiveDate,
452 ) -> Self {
453 Self {
454 rule_id,
455 name,
456 elimination_type: EliminationType::ICBalances,
457 source_account_pattern: receivable_pattern,
458 target_account_pattern: payable_pattern,
459 company_pairs: Vec::new(),
460 priority: 10,
461 is_active: true,
462 effective_date,
463 end_date: None,
464 auto_generate: true,
465 }
466 }
467
468 pub fn is_active_on(&self, date: NaiveDate) -> bool {
470 self.is_active && date >= self.effective_date && self.end_date.is_none_or(|end| date <= end)
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ICAggregatedBalance {
477 pub creditor_company: String,
479 pub debtor_company: String,
481 pub receivable_account: String,
483 pub payable_account: String,
485 pub receivable_balance: Decimal,
487 pub payable_balance: Decimal,
489 pub difference: Decimal,
491 pub currency: String,
493 pub as_of_date: NaiveDate,
495 pub is_matched: bool,
497}
498
499impl ICAggregatedBalance {
500 pub fn new(
502 creditor_company: String,
503 debtor_company: String,
504 receivable_account: String,
505 payable_account: String,
506 currency: String,
507 as_of_date: NaiveDate,
508 ) -> Self {
509 Self {
510 creditor_company,
511 debtor_company,
512 receivable_account,
513 payable_account,
514 receivable_balance: Decimal::ZERO,
515 payable_balance: Decimal::ZERO,
516 difference: Decimal::ZERO,
517 currency,
518 as_of_date,
519 is_matched: true,
520 }
521 }
522
523 pub fn set_balances(&mut self, receivable: Decimal, payable: Decimal) {
525 self.receivable_balance = receivable;
526 self.payable_balance = payable;
527 self.difference = receivable - payable;
528 self.is_matched = self.difference == Decimal::ZERO;
529 }
530
531 pub fn elimination_amount(&self) -> Decimal {
533 self.receivable_balance.min(self.payable_balance)
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct ConsolidationJournal {
540 pub consolidation_entity: String,
542 pub fiscal_period: String,
544 pub status: ConsolidationStatus,
546 pub entries: Vec<EliminationEntry>,
548 pub summary: HashMap<EliminationType, EliminationSummary>,
550 pub total_debits: Decimal,
552 pub total_credits: Decimal,
554 pub is_balanced: bool,
556 pub created_date: NaiveDate,
558 pub modified_date: NaiveDate,
560 pub approved_by: Option<String>,
562 pub approved_date: Option<NaiveDate>,
564}
565
566impl ConsolidationJournal {
567 pub fn new(
569 consolidation_entity: String,
570 fiscal_period: String,
571 created_date: NaiveDate,
572 ) -> Self {
573 Self {
574 consolidation_entity,
575 fiscal_period,
576 status: ConsolidationStatus::Draft,
577 entries: Vec::new(),
578 summary: HashMap::new(),
579 total_debits: Decimal::ZERO,
580 total_credits: Decimal::ZERO,
581 is_balanced: true,
582 created_date,
583 modified_date: created_date,
584 approved_by: None,
585 approved_date: None,
586 }
587 }
588
589 pub fn add_entry(&mut self, entry: EliminationEntry) {
591 self.total_debits += entry.total_debit;
592 self.total_credits += entry.total_credit;
593 self.is_balanced = self.total_debits == self.total_credits;
594
595 let summary = self
597 .summary
598 .entry(entry.elimination_type)
599 .or_insert_with(|| EliminationSummary {
600 elimination_type: entry.elimination_type,
601 entry_count: 0,
602 total_amount: Decimal::ZERO,
603 });
604 summary.entry_count += 1;
605 summary.total_amount += entry.total_debit;
606
607 self.entries.push(entry);
608 self.modified_date = chrono::Utc::now().date_naive();
609 }
610
611 pub fn submit(&mut self) {
613 if self.is_balanced {
614 self.status = ConsolidationStatus::PendingApproval;
615 }
616 }
617
618 pub fn approve(&mut self, approved_by: String) {
620 self.status = ConsolidationStatus::Approved;
621 self.approved_by = Some(approved_by);
622 self.approved_date = Some(chrono::Utc::now().date_naive());
623 }
624
625 pub fn post(&mut self) {
627 if self.status == ConsolidationStatus::Approved {
628 self.status = ConsolidationStatus::Posted;
629 }
630 }
631}
632
633#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
635#[serde(rename_all = "snake_case")]
636pub enum ConsolidationStatus {
637 #[default]
639 Draft,
640 PendingApproval,
642 Approved,
644 Posted,
646 Reversed,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct EliminationSummary {
653 pub elimination_type: EliminationType,
655 pub entry_count: usize,
657 pub total_amount: Decimal,
659}
660
661#[cfg(test)]
662#[allow(clippy::unwrap_used)]
663mod tests {
664 use super::*;
665 use rust_decimal_macros::dec;
666
667 #[test]
668 fn test_elimination_type_properties() {
669 assert!(EliminationType::ICRevenueExpense.affects_pnl());
670 assert!(!EliminationType::ICBalances.affects_pnl());
671
672 assert!(EliminationType::ICBalances.is_recurring());
673 assert!(!EliminationType::InvestmentEquity.is_recurring());
674 }
675
676 #[test]
677 fn test_ic_balance_elimination() {
678 let entry = EliminationEntry::create_ic_balance_elimination(
679 "ELIM001".to_string(),
680 "GROUP".to_string(),
681 "202206".to_string(),
682 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
683 "1000",
684 "1100",
685 "1310",
686 "2110",
687 dec!(50000),
688 "USD".to_string(),
689 );
690
691 assert_eq!(entry.lines.len(), 2);
692 assert!(entry.is_balanced());
693 assert_eq!(entry.total_debit, dec!(50000));
694 assert_eq!(entry.total_credit, dec!(50000));
695 }
696
697 #[test]
698 fn test_ic_revenue_expense_elimination() {
699 let entry = EliminationEntry::create_ic_revenue_expense_elimination(
700 "ELIM002".to_string(),
701 "GROUP".to_string(),
702 "202206".to_string(),
703 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
704 "1000",
705 "1100",
706 "4100",
707 "5100",
708 dec!(100000),
709 "USD".to_string(),
710 );
711
712 assert!(entry.is_balanced());
713 assert!(entry.elimination_type.affects_pnl());
714 }
715
716 #[test]
717 fn test_aggregated_balance() {
718 let mut balance = ICAggregatedBalance::new(
719 "1000".to_string(),
720 "1100".to_string(),
721 "1310".to_string(),
722 "2110".to_string(),
723 "USD".to_string(),
724 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
725 );
726
727 balance.set_balances(dec!(50000), dec!(50000));
728 assert!(balance.is_matched);
729 assert_eq!(balance.elimination_amount(), dec!(50000));
730
731 balance.set_balances(dec!(50000), dec!(48000));
732 assert!(!balance.is_matched);
733 assert_eq!(balance.difference, dec!(2000));
734 assert_eq!(balance.elimination_amount(), dec!(48000));
735 }
736
737 #[test]
738 fn test_consolidation_journal() {
739 let mut journal = ConsolidationJournal::new(
740 "GROUP".to_string(),
741 "202206".to_string(),
742 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
743 );
744
745 let entry = EliminationEntry::create_ic_balance_elimination(
746 "ELIM001".to_string(),
747 "GROUP".to_string(),
748 "202206".to_string(),
749 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
750 "1000",
751 "1100",
752 "1310",
753 "2110",
754 dec!(50000),
755 "USD".to_string(),
756 );
757
758 journal.add_entry(entry);
759
760 assert_eq!(journal.entries.len(), 1);
761 assert!(journal.is_balanced);
762 assert_eq!(journal.status, ConsolidationStatus::Draft);
763
764 journal.submit();
765 assert_eq!(journal.status, ConsolidationStatus::PendingApproval);
766 }
767}