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
476            && date >= self.effective_date
477            && self.end_date.map_or(true, |end| date <= end)
478    }
479}
480
481/// Aggregated IC balances for elimination.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ICAggregatedBalance {
484    /// Seller/creditor company.
485    pub creditor_company: String,
486    /// Buyer/debtor company.
487    pub debtor_company: String,
488    /// IC receivable account.
489    pub receivable_account: String,
490    /// IC payable account.
491    pub payable_account: String,
492    /// Receivable balance (per creditor's books).
493    pub receivable_balance: Decimal,
494    /// Payable balance (per debtor's books).
495    pub payable_balance: Decimal,
496    /// Difference (should be zero if matched).
497    pub difference: Decimal,
498    /// Currency.
499    pub currency: String,
500    /// As-of date.
501    pub as_of_date: NaiveDate,
502    /// Is fully matched?
503    pub is_matched: bool,
504}
505
506impl ICAggregatedBalance {
507    /// Create a new aggregated balance.
508    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    /// Set balances and calculate difference.
531    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    /// Get the elimination amount (minimum of both sides).
539    pub fn elimination_amount(&self) -> Decimal {
540        self.receivable_balance.min(self.payable_balance)
541    }
542}
543
544/// Consolidation journal for a period.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct ConsolidationJournal {
547    /// Consolidation entity.
548    pub consolidation_entity: String,
549    /// Fiscal period.
550    pub fiscal_period: String,
551    /// Journal status.
552    pub status: ConsolidationStatus,
553    /// All elimination entries for this period.
554    pub entries: Vec<EliminationEntry>,
555    /// Summary by elimination type.
556    pub summary: HashMap<EliminationType, EliminationSummary>,
557    /// Total debits.
558    pub total_debits: Decimal,
559    /// Total credits.
560    pub total_credits: Decimal,
561    /// Is journal balanced?
562    pub is_balanced: bool,
563    /// Created date.
564    pub created_date: NaiveDate,
565    /// Last modified date.
566    pub modified_date: NaiveDate,
567    /// Approved by (if applicable).
568    pub approved_by: Option<String>,
569    /// Approval date.
570    pub approved_date: Option<NaiveDate>,
571}
572
573impl ConsolidationJournal {
574    /// Create a new consolidation journal.
575    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    /// Add an elimination entry.
597    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        // Update summary
603        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    /// Submit for approval.
619    pub fn submit(&mut self) {
620        if self.is_balanced {
621            self.status = ConsolidationStatus::PendingApproval;
622        }
623    }
624
625    /// Approve the journal.
626    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    /// Post the journal.
633    pub fn post(&mut self) {
634        if self.status == ConsolidationStatus::Approved {
635            self.status = ConsolidationStatus::Posted;
636        }
637    }
638}
639
640/// Status of consolidation journal.
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
642#[serde(rename_all = "snake_case")]
643pub enum ConsolidationStatus {
644    /// Draft - still being prepared.
645    #[default]
646    Draft,
647    /// Submitted for approval.
648    PendingApproval,
649    /// Approved.
650    Approved,
651    /// Posted to consolidated financials.
652    Posted,
653    /// Reversed.
654    Reversed,
655}
656
657/// Summary statistics for an elimination type.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct EliminationSummary {
660    /// Elimination type.
661    pub elimination_type: EliminationType,
662    /// Number of entries.
663    pub entry_count: usize,
664    /// Total amount eliminated.
665    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}