Skip to main content

datasynth_core/models/intercompany/
elimination.rs

1//! Consolidation elimination models for intercompany transactions.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Types of consolidation eliminations.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EliminationType {
12    /// Eliminate intercompany receivables and payables.
13    ICBalances,
14    /// Eliminate intercompany revenue and expense.
15    ICRevenueExpense,
16    /// Eliminate intercompany profit in inventory.
17    ICProfitInInventory,
18    /// Eliminate intercompany profit in fixed assets.
19    ICProfitInFixedAssets,
20    /// Eliminate investment in subsidiary against equity.
21    InvestmentEquity,
22    /// Eliminate intercompany dividends.
23    ICDividends,
24    /// Eliminate intercompany loan balances.
25    ICLoans,
26    /// Eliminate intercompany interest income/expense.
27    ICInterest,
28    /// Minority interest (non-controlling interest) recognition.
29    MinorityInterest,
30    /// Goodwill recognition from acquisition.
31    Goodwill,
32    /// Currency translation adjustment.
33    CurrencyTranslation,
34}
35
36impl EliminationType {
37    /// Get the description for this elimination type.
38    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    /// Check if this elimination affects profit/loss.
55    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    /// Check if this elimination is recurring every period.
67    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/// A consolidation elimination entry.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EliminationEntry {
82    /// Unique elimination entry ID.
83    pub entry_id: String,
84    /// Elimination type.
85    pub elimination_type: EliminationType,
86    /// Consolidation entity (group company).
87    pub consolidation_entity: String,
88    /// Fiscal period (YYYYMM format).
89    pub fiscal_period: String,
90    /// Entry date.
91    pub entry_date: NaiveDate,
92    /// Related companies (for IC eliminations).
93    pub related_companies: Vec<String>,
94    /// Elimination journal lines.
95    pub lines: Vec<EliminationLine>,
96    /// Total debit amount.
97    pub total_debit: Decimal,
98    /// Total credit amount.
99    pub total_credit: Decimal,
100    /// Currency.
101    pub currency: String,
102    /// Is this a permanent or temporary elimination?
103    pub is_permanent: bool,
104    /// Related IC references (for traceability).
105    pub ic_references: Vec<String>,
106    /// Elimination description.
107    pub description: String,
108    /// Created by (user/system).
109    pub created_by: String,
110    /// Creation timestamp.
111    pub created_at: chrono::NaiveDateTime,
112}
113
114impl EliminationEntry {
115    /// Create a new elimination entry.
116    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    /// Add an elimination line.
144    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    /// Check if the entry is balanced.
154    pub fn is_balanced(&self) -> bool {
155        self.total_debit == self.total_credit
156    }
157
158    /// Create an IC balance elimination (receivable/payable).
159    #[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        // Debit the payable (reduce liability)
185        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        // Credit the receivable (reduce asset)
196        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    /// Create an IC revenue/expense elimination.
210    #[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        // Debit revenue (reduce income)
239        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        // Credit expense (reduce expense)
250        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    /// Create an unrealized profit in inventory elimination.
264    #[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        // Debit retained earnings/COGS (reduce profit)
291        entry.add_line(EliminationLine {
292            line_number: 1,
293            company: seller.to_string(),
294            account: "5000".to_string(), // COGS or adjustment account
295            is_debit: true,
296            amount: unrealized_profit,
297            currency: currency.clone(),
298            description: "Eliminate unrealized profit".to_string(),
299        });
300
301        // Credit inventory (reduce asset value)
302        entry.add_line(EliminationLine {
303            line_number: 2,
304            company: buyer.to_string(),
305            account: "1400".to_string(), // Inventory account
306            is_debit: false,
307            amount: unrealized_profit,
308            currency,
309            description: "Reduce inventory to cost".to_string(),
310        });
311
312        entry
313    }
314
315    /// Create investment/equity elimination.
316    #[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)>, // (account, amount)
326        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        // Debit equity components of subsidiary
347        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        // Debit goodwill if applicable
361        if let Some(goodwill_amount) = goodwill {
362            entry.add_line(EliminationLine {
363                line_number,
364                company: consol_entity.clone(),
365                account: "1800".to_string(), // Goodwill account
366                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        // Credit investment account
375        entry.add_line(EliminationLine {
376            line_number,
377            company: parent.to_string(),
378            account: "1510".to_string(), // Investment in subsidiary
379            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        // Credit minority interest if applicable
387        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(), // Non-controlling interest
392                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/// A single line in an elimination entry.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct EliminationLine {
406    /// Line number.
407    pub line_number: u32,
408    /// Company code this line affects.
409    pub company: String,
410    /// Account code.
411    pub account: String,
412    /// Is this a debit (true) or credit (false)?
413    pub is_debit: bool,
414    /// Amount.
415    pub amount: Decimal,
416    /// Currency.
417    pub currency: String,
418    /// Line description.
419    pub description: String,
420}
421
422/// Consolidation elimination rule definition.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct EliminationRule {
425    /// Rule identifier.
426    pub rule_id: String,
427    /// Rule name.
428    pub name: String,
429    /// Elimination type this rule handles.
430    pub elimination_type: EliminationType,
431    /// Source account pattern (regex or exact).
432    pub source_account_pattern: String,
433    /// Target account pattern (regex or exact).
434    pub target_account_pattern: String,
435    /// Applies to specific company pairs (empty = all).
436    pub company_pairs: Vec<(String, String)>,
437    /// Priority (lower = higher priority).
438    pub priority: u32,
439    /// Is this rule active?
440    pub is_active: bool,
441    /// Effective date.
442    pub effective_date: NaiveDate,
443    /// End date (if rule expires).
444    pub end_date: Option<NaiveDate>,
445    /// Auto-generate eliminations?
446    pub auto_generate: bool,
447}
448
449impl EliminationRule {
450    /// Create a new IC balance elimination rule.
451    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    /// Check if rule is active on a given date.
474    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/// Aggregated IC balances for elimination.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ICAggregatedBalance {
482    /// Seller/creditor company.
483    pub creditor_company: String,
484    /// Buyer/debtor company.
485    pub debtor_company: String,
486    /// IC receivable account.
487    pub receivable_account: String,
488    /// IC payable account.
489    pub payable_account: String,
490    /// Receivable balance (per creditor's books).
491    pub receivable_balance: Decimal,
492    /// Payable balance (per debtor's books).
493    pub payable_balance: Decimal,
494    /// Difference (should be zero if matched).
495    pub difference: Decimal,
496    /// Currency.
497    pub currency: String,
498    /// As-of date.
499    pub as_of_date: NaiveDate,
500    /// Is fully matched?
501    pub is_matched: bool,
502}
503
504impl ICAggregatedBalance {
505    /// Create a new aggregated balance.
506    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    /// Set balances and calculate difference.
529    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    /// Get the elimination amount (minimum of both sides).
537    pub fn elimination_amount(&self) -> Decimal {
538        self.receivable_balance.min(self.payable_balance)
539    }
540}
541
542/// Consolidation journal for a period.
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct ConsolidationJournal {
545    /// Consolidation entity.
546    pub consolidation_entity: String,
547    /// Fiscal period.
548    pub fiscal_period: String,
549    /// Journal status.
550    pub status: ConsolidationStatus,
551    /// All elimination entries for this period.
552    pub entries: Vec<EliminationEntry>,
553    /// Summary by elimination type.
554    pub summary: HashMap<EliminationType, EliminationSummary>,
555    /// Total debits.
556    pub total_debits: Decimal,
557    /// Total credits.
558    pub total_credits: Decimal,
559    /// Is journal balanced?
560    pub is_balanced: bool,
561    /// Created date.
562    pub created_date: NaiveDate,
563    /// Last modified date.
564    pub modified_date: NaiveDate,
565    /// Approved by (if applicable).
566    pub approved_by: Option<String>,
567    /// Approval date.
568    pub approved_date: Option<NaiveDate>,
569}
570
571impl ConsolidationJournal {
572    /// Create a new consolidation journal.
573    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    /// Add an elimination entry.
595    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        // Update summary
601        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    /// Submit for approval.
617    pub fn submit(&mut self) {
618        if self.is_balanced {
619            self.status = ConsolidationStatus::PendingApproval;
620        }
621    }
622
623    /// Approve the journal.
624    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    /// Post the journal.
631    pub fn post(&mut self) {
632        if self.status == ConsolidationStatus::Approved {
633            self.status = ConsolidationStatus::Posted;
634        }
635    }
636}
637
638/// Status of consolidation journal.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
640#[serde(rename_all = "snake_case")]
641pub enum ConsolidationStatus {
642    /// Draft - still being prepared.
643    #[default]
644    Draft,
645    /// Submitted for approval.
646    PendingApproval,
647    /// Approved.
648    Approved,
649    /// Posted to consolidated financials.
650    Posted,
651    /// Reversed.
652    Reversed,
653}
654
655/// Summary statistics for an elimination type.
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct EliminationSummary {
658    /// Elimination type.
659    pub elimination_type: EliminationType,
660    /// Number of entries.
661    pub entry_count: usize,
662    /// Total amount eliminated.
663    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}