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 {company1} and {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!("Eliminate IC revenue/expense between {seller} and {buyer}");
234
235        // Debit revenue (reduce income)
236        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        // Credit expense (reduce expense)
247        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    /// Create an unrealized profit in inventory elimination.
261    #[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        // Debit retained earnings/COGS (reduce profit)
286        entry.add_line(EliminationLine {
287            line_number: 1,
288            company: seller.to_string(),
289            account: "5000".to_string(), // COGS or adjustment account
290            is_debit: true,
291            amount: unrealized_profit,
292            currency: currency.clone(),
293            description: "Eliminate unrealized profit".to_string(),
294        });
295
296        // Credit inventory (reduce asset value)
297        entry.add_line(EliminationLine {
298            line_number: 2,
299            company: buyer.to_string(),
300            account: "1400".to_string(), // Inventory account
301            is_debit: false,
302            amount: unrealized_profit,
303            currency,
304            description: "Reduce inventory to cost".to_string(),
305        });
306
307        entry
308    }
309
310    /// Create investment/equity elimination.
311    #[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)>, // (account, amount)
321        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        // Debit equity components of subsidiary
342        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        // Debit goodwill if applicable
356        if let Some(goodwill_amount) = goodwill {
357            entry.add_line(EliminationLine {
358                line_number,
359                company: consol_entity.clone(),
360                account: "1800".to_string(), // Goodwill account
361                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        // Credit investment account
370        entry.add_line(EliminationLine {
371            line_number,
372            company: parent.to_string(),
373            account: "1510".to_string(), // Investment in subsidiary
374            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        // Credit minority interest if applicable
382        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(), // Non-controlling interest
387                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/// A single line in an elimination entry.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct EliminationLine {
401    /// Line number.
402    pub line_number: u32,
403    /// Company code this line affects.
404    pub company: String,
405    /// Account code.
406    pub account: String,
407    /// Is this a debit (true) or credit (false)?
408    pub is_debit: bool,
409    /// Amount.
410    pub amount: Decimal,
411    /// Currency.
412    pub currency: String,
413    /// Line description.
414    pub description: String,
415}
416
417/// Consolidation elimination rule definition.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct EliminationRule {
420    /// Rule identifier.
421    pub rule_id: String,
422    /// Rule name.
423    pub name: String,
424    /// Elimination type this rule handles.
425    pub elimination_type: EliminationType,
426    /// Source account pattern (regex or exact).
427    pub source_account_pattern: String,
428    /// Target account pattern (regex or exact).
429    pub target_account_pattern: String,
430    /// Applies to specific company pairs (empty = all).
431    pub company_pairs: Vec<(String, String)>,
432    /// Priority (lower = higher priority).
433    pub priority: u32,
434    /// Is this rule active?
435    pub is_active: bool,
436    /// Effective date.
437    pub effective_date: NaiveDate,
438    /// End date (if rule expires).
439    pub end_date: Option<NaiveDate>,
440    /// Auto-generate eliminations?
441    pub auto_generate: bool,
442}
443
444impl EliminationRule {
445    /// Create a new IC balance elimination rule.
446    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    /// Check if rule is active on a given date.
469    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/// Aggregated IC balances for elimination.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ICAggregatedBalance {
477    /// Seller/creditor company.
478    pub creditor_company: String,
479    /// Buyer/debtor company.
480    pub debtor_company: String,
481    /// IC receivable account.
482    pub receivable_account: String,
483    /// IC payable account.
484    pub payable_account: String,
485    /// Receivable balance (per creditor's books).
486    pub receivable_balance: Decimal,
487    /// Payable balance (per debtor's books).
488    pub payable_balance: Decimal,
489    /// Difference (should be zero if matched).
490    pub difference: Decimal,
491    /// Currency.
492    pub currency: String,
493    /// As-of date.
494    pub as_of_date: NaiveDate,
495    /// Is fully matched?
496    pub is_matched: bool,
497}
498
499impl ICAggregatedBalance {
500    /// Create a new aggregated balance.
501    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    /// Set balances and calculate difference.
524    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    /// Get the elimination amount (minimum of both sides).
532    pub fn elimination_amount(&self) -> Decimal {
533        self.receivable_balance.min(self.payable_balance)
534    }
535}
536
537/// Consolidation journal for a period.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct ConsolidationJournal {
540    /// Consolidation entity.
541    pub consolidation_entity: String,
542    /// Fiscal period.
543    pub fiscal_period: String,
544    /// Journal status.
545    pub status: ConsolidationStatus,
546    /// All elimination entries for this period.
547    pub entries: Vec<EliminationEntry>,
548    /// Summary by elimination type.
549    pub summary: HashMap<EliminationType, EliminationSummary>,
550    /// Total debits.
551    pub total_debits: Decimal,
552    /// Total credits.
553    pub total_credits: Decimal,
554    /// Is journal balanced?
555    pub is_balanced: bool,
556    /// Created date.
557    pub created_date: NaiveDate,
558    /// Last modified date.
559    pub modified_date: NaiveDate,
560    /// Approved by (if applicable).
561    pub approved_by: Option<String>,
562    /// Approval date.
563    pub approved_date: Option<NaiveDate>,
564}
565
566impl ConsolidationJournal {
567    /// Create a new consolidation journal.
568    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    /// Add an elimination entry.
590    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        // Update summary
596        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    /// Submit for approval.
612    pub fn submit(&mut self) {
613        if self.is_balanced {
614            self.status = ConsolidationStatus::PendingApproval;
615        }
616    }
617
618    /// Approve the journal.
619    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    /// Post the journal.
626    pub fn post(&mut self) {
627        if self.status == ConsolidationStatus::Approved {
628            self.status = ConsolidationStatus::Posted;
629        }
630    }
631}
632
633/// Status of consolidation journal.
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
635#[serde(rename_all = "snake_case")]
636pub enum ConsolidationStatus {
637    /// Draft - still being prepared.
638    #[default]
639    Draft,
640    /// Submitted for approval.
641    PendingApproval,
642    /// Approved.
643    Approved,
644    /// Posted to consolidated financials.
645    Posted,
646    /// Reversed.
647    Reversed,
648}
649
650/// Summary statistics for an elimination type.
651#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct EliminationSummary {
653    /// Elimination type.
654    pub elimination_type: EliminationType,
655    /// Number of entries.
656    pub entry_count: usize,
657    /// Total amount eliminated.
658    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}