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
476 && date >= self.effective_date
477 && self.end_date.map_or(true, |end| date <= end)
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ICAggregatedBalance {
484 pub creditor_company: String,
486 pub debtor_company: String,
488 pub receivable_account: String,
490 pub payable_account: String,
492 pub receivable_balance: Decimal,
494 pub payable_balance: Decimal,
496 pub difference: Decimal,
498 pub currency: String,
500 pub as_of_date: NaiveDate,
502 pub is_matched: bool,
504}
505
506impl ICAggregatedBalance {
507 pub fn new(
509 creditor_company: String,
510 debtor_company: String,
511 receivable_account: String,
512 payable_account: String,
513 currency: String,
514 as_of_date: NaiveDate,
515 ) -> Self {
516 Self {
517 creditor_company,
518 debtor_company,
519 receivable_account,
520 payable_account,
521 receivable_balance: Decimal::ZERO,
522 payable_balance: Decimal::ZERO,
523 difference: Decimal::ZERO,
524 currency,
525 as_of_date,
526 is_matched: true,
527 }
528 }
529
530 pub fn set_balances(&mut self, receivable: Decimal, payable: Decimal) {
532 self.receivable_balance = receivable;
533 self.payable_balance = payable;
534 self.difference = receivable - payable;
535 self.is_matched = self.difference == Decimal::ZERO;
536 }
537
538 pub fn elimination_amount(&self) -> Decimal {
540 self.receivable_balance.min(self.payable_balance)
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct ConsolidationJournal {
547 pub consolidation_entity: String,
549 pub fiscal_period: String,
551 pub status: ConsolidationStatus,
553 pub entries: Vec<EliminationEntry>,
555 pub summary: HashMap<EliminationType, EliminationSummary>,
557 pub total_debits: Decimal,
559 pub total_credits: Decimal,
561 pub is_balanced: bool,
563 pub created_date: NaiveDate,
565 pub modified_date: NaiveDate,
567 pub approved_by: Option<String>,
569 pub approved_date: Option<NaiveDate>,
571}
572
573impl ConsolidationJournal {
574 pub fn new(
576 consolidation_entity: String,
577 fiscal_period: String,
578 created_date: NaiveDate,
579 ) -> Self {
580 Self {
581 consolidation_entity,
582 fiscal_period,
583 status: ConsolidationStatus::Draft,
584 entries: Vec::new(),
585 summary: HashMap::new(),
586 total_debits: Decimal::ZERO,
587 total_credits: Decimal::ZERO,
588 is_balanced: true,
589 created_date,
590 modified_date: created_date,
591 approved_by: None,
592 approved_date: None,
593 }
594 }
595
596 pub fn add_entry(&mut self, entry: EliminationEntry) {
598 self.total_debits += entry.total_debit;
599 self.total_credits += entry.total_credit;
600 self.is_balanced = self.total_debits == self.total_credits;
601
602 let summary = self
604 .summary
605 .entry(entry.elimination_type)
606 .or_insert_with(|| EliminationSummary {
607 elimination_type: entry.elimination_type,
608 entry_count: 0,
609 total_amount: Decimal::ZERO,
610 });
611 summary.entry_count += 1;
612 summary.total_amount += entry.total_debit;
613
614 self.entries.push(entry);
615 self.modified_date = chrono::Utc::now().date_naive();
616 }
617
618 pub fn submit(&mut self) {
620 if self.is_balanced {
621 self.status = ConsolidationStatus::PendingApproval;
622 }
623 }
624
625 pub fn approve(&mut self, approved_by: String) {
627 self.status = ConsolidationStatus::Approved;
628 self.approved_by = Some(approved_by);
629 self.approved_date = Some(chrono::Utc::now().date_naive());
630 }
631
632 pub fn post(&mut self) {
634 if self.status == ConsolidationStatus::Approved {
635 self.status = ConsolidationStatus::Posted;
636 }
637 }
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
642#[serde(rename_all = "snake_case")]
643pub enum ConsolidationStatus {
644 #[default]
646 Draft,
647 PendingApproval,
649 Approved,
651 Posted,
653 Reversed,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct EliminationSummary {
660 pub elimination_type: EliminationType,
662 pub entry_count: usize,
664 pub total_amount: Decimal,
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use rust_decimal_macros::dec;
672
673 #[test]
674 fn test_elimination_type_properties() {
675 assert!(EliminationType::ICRevenueExpense.affects_pnl());
676 assert!(!EliminationType::ICBalances.affects_pnl());
677
678 assert!(EliminationType::ICBalances.is_recurring());
679 assert!(!EliminationType::InvestmentEquity.is_recurring());
680 }
681
682 #[test]
683 fn test_ic_balance_elimination() {
684 let entry = EliminationEntry::create_ic_balance_elimination(
685 "ELIM001".to_string(),
686 "GROUP".to_string(),
687 "202206".to_string(),
688 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
689 "1000",
690 "1100",
691 "1310",
692 "2110",
693 dec!(50000),
694 "USD".to_string(),
695 );
696
697 assert_eq!(entry.lines.len(), 2);
698 assert!(entry.is_balanced());
699 assert_eq!(entry.total_debit, dec!(50000));
700 assert_eq!(entry.total_credit, dec!(50000));
701 }
702
703 #[test]
704 fn test_ic_revenue_expense_elimination() {
705 let entry = EliminationEntry::create_ic_revenue_expense_elimination(
706 "ELIM002".to_string(),
707 "GROUP".to_string(),
708 "202206".to_string(),
709 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
710 "1000",
711 "1100",
712 "4100",
713 "5100",
714 dec!(100000),
715 "USD".to_string(),
716 );
717
718 assert!(entry.is_balanced());
719 assert!(entry.elimination_type.affects_pnl());
720 }
721
722 #[test]
723 fn test_aggregated_balance() {
724 let mut balance = ICAggregatedBalance::new(
725 "1000".to_string(),
726 "1100".to_string(),
727 "1310".to_string(),
728 "2110".to_string(),
729 "USD".to_string(),
730 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
731 );
732
733 balance.set_balances(dec!(50000), dec!(50000));
734 assert!(balance.is_matched);
735 assert_eq!(balance.elimination_amount(), dec!(50000));
736
737 balance.set_balances(dec!(50000), dec!(48000));
738 assert!(!balance.is_matched);
739 assert_eq!(balance.difference, dec!(2000));
740 assert_eq!(balance.elimination_amount(), dec!(48000));
741 }
742
743 #[test]
744 fn test_consolidation_journal() {
745 let mut journal = ConsolidationJournal::new(
746 "GROUP".to_string(),
747 "202206".to_string(),
748 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
749 );
750
751 let entry = EliminationEntry::create_ic_balance_elimination(
752 "ELIM001".to_string(),
753 "GROUP".to_string(),
754 "202206".to_string(),
755 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
756 "1000",
757 "1100",
758 "1310",
759 "2110",
760 dec!(50000),
761 "USD".to_string(),
762 );
763
764 journal.add_entry(entry);
765
766 assert_eq!(journal.entries.len(), 1);
767 assert!(journal.is_balanced);
768 assert_eq!(journal.status, ConsolidationStatus::Draft);
769
770 journal.submit();
771 assert_eq!(journal.status, ConsolidationStatus::PendingApproval);
772 }
773}