Skip to main content

datasynth_core/models/intercompany/
relationship.rs

1//! Intercompany relationship and ownership structure models.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Represents an intercompany relationship between two legal entities.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct IntercompanyRelationship {
11    /// Unique identifier for this relationship.
12    pub relationship_id: String,
13    /// Parent/investing company code.
14    pub parent_company: String,
15    /// Subsidiary/investee company code.
16    pub subsidiary_company: String,
17    /// Ownership percentage (0.0 to 100.0).
18    pub ownership_percentage: Decimal,
19    /// Consolidation method based on ownership and control.
20    pub consolidation_method: ConsolidationMethod,
21    /// Transfer pricing policy identifier.
22    pub transfer_pricing_policy: Option<String>,
23    /// Date the relationship became effective.
24    pub effective_date: NaiveDate,
25    /// Date the relationship ended (if applicable).
26    pub end_date: Option<NaiveDate>,
27    /// Whether this is a direct or indirect holding.
28    pub holding_type: HoldingType,
29    /// Functional currency of the subsidiary.
30    pub functional_currency: String,
31    /// Whether elimination entries are required.
32    pub requires_elimination: bool,
33    /// Segment or business unit for reporting.
34    pub reporting_segment: Option<String>,
35}
36
37impl IntercompanyRelationship {
38    /// Create a new intercompany relationship.
39    pub fn new(
40        relationship_id: String,
41        parent_company: String,
42        subsidiary_company: String,
43        ownership_percentage: Decimal,
44        effective_date: NaiveDate,
45    ) -> Self {
46        let consolidation_method = ConsolidationMethod::from_ownership(ownership_percentage);
47        let requires_elimination = consolidation_method != ConsolidationMethod::Equity;
48
49        Self {
50            relationship_id,
51            parent_company,
52            subsidiary_company,
53            ownership_percentage,
54            consolidation_method,
55            transfer_pricing_policy: None,
56            effective_date,
57            end_date: None,
58            holding_type: HoldingType::Direct,
59            functional_currency: "USD".to_string(),
60            requires_elimination,
61            reporting_segment: None,
62        }
63    }
64
65    /// Check if the relationship is active on a given date.
66    pub fn is_active_on(&self, date: NaiveDate) -> bool {
67        date >= self.effective_date && self.end_date.map_or(true, |end| date <= end)
68    }
69
70    /// Check if this represents a controlling interest.
71    pub fn is_controlling(&self) -> bool {
72        self.ownership_percentage > Decimal::from(50)
73    }
74
75    /// Check if this represents a significant influence.
76    pub fn has_significant_influence(&self) -> bool {
77        self.ownership_percentage >= Decimal::from(20)
78    }
79}
80
81/// Consolidation method based on level of control.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
83#[serde(rename_all = "snake_case")]
84pub enum ConsolidationMethod {
85    /// Full consolidation (>50% ownership, control).
86    #[default]
87    Full,
88    /// Proportional consolidation (joint ventures).
89    Proportional,
90    /// Equity method (20-50% ownership, significant influence).
91    Equity,
92    /// Cost method (<20% ownership, no significant influence).
93    Cost,
94}
95
96impl ConsolidationMethod {
97    /// Determine consolidation method based on ownership percentage.
98    pub fn from_ownership(ownership_pct: Decimal) -> Self {
99        if ownership_pct > Decimal::from(50) {
100            Self::Full
101        } else if ownership_pct >= Decimal::from(20) {
102            Self::Equity
103        } else {
104            Self::Cost
105        }
106    }
107
108    /// Check if full elimination is required.
109    pub fn requires_full_elimination(&self) -> bool {
110        matches!(self, Self::Full)
111    }
112
113    /// Check if proportional elimination is required.
114    pub fn requires_proportional_elimination(&self) -> bool {
115        matches!(self, Self::Proportional)
116    }
117
118    /// Returns the string representation of the consolidation method.
119    pub fn as_str(&self) -> &'static str {
120        match self {
121            Self::Full => "Full",
122            Self::Proportional => "Proportional",
123            Self::Equity => "Equity",
124            Self::Cost => "Cost",
125        }
126    }
127}
128
129/// Type of holding in the ownership structure.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum HoldingType {
133    /// Direct ownership by the parent.
134    #[default]
135    Direct,
136    /// Indirect ownership through another subsidiary.
137    Indirect,
138    /// Reciprocal/cross-holding.
139    Reciprocal,
140}
141
142/// Represents the complete ownership structure of a corporate group.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct OwnershipStructure {
145    /// The ultimate parent company code.
146    pub ultimate_parent: String,
147    /// All intercompany relationships.
148    pub relationships: Vec<IntercompanyRelationship>,
149    /// Effective ownership percentages (company -> effective %).
150    effective_ownership: HashMap<String, Decimal>,
151    /// Direct subsidiaries by parent.
152    subsidiaries_by_parent: HashMap<String, Vec<String>>,
153}
154
155impl OwnershipStructure {
156    /// Create a new ownership structure.
157    pub fn new(ultimate_parent: String) -> Self {
158        Self {
159            ultimate_parent,
160            relationships: Vec::new(),
161            effective_ownership: HashMap::new(),
162            subsidiaries_by_parent: HashMap::new(),
163        }
164    }
165
166    /// Add a relationship to the ownership structure.
167    pub fn add_relationship(&mut self, relationship: IntercompanyRelationship) {
168        // Update subsidiaries index
169        self.subsidiaries_by_parent
170            .entry(relationship.parent_company.clone())
171            .or_default()
172            .push(relationship.subsidiary_company.clone());
173
174        self.relationships.push(relationship);
175
176        // Recalculate effective ownership
177        self.calculate_effective_ownership();
178    }
179
180    /// Get all relationships for a specific parent company.
181    pub fn get_relationships_for_parent(&self, parent: &str) -> Vec<&IntercompanyRelationship> {
182        self.relationships
183            .iter()
184            .filter(|r| r.parent_company == parent)
185            .collect()
186    }
187
188    /// Get all relationships for a specific subsidiary.
189    pub fn get_relationships_for_subsidiary(
190        &self,
191        subsidiary: &str,
192    ) -> Vec<&IntercompanyRelationship> {
193        self.relationships
194            .iter()
195            .filter(|r| r.subsidiary_company == subsidiary)
196            .collect()
197    }
198
199    /// Get the direct parent of a company.
200    pub fn get_direct_parent(&self, company: &str) -> Option<&str> {
201        self.relationships
202            .iter()
203            .find(|r| r.subsidiary_company == company && r.holding_type == HoldingType::Direct)
204            .map(|r| r.parent_company.as_str())
205    }
206
207    /// Get direct subsidiaries of a company.
208    pub fn get_direct_subsidiaries(&self, parent: &str) -> Vec<&str> {
209        self.subsidiaries_by_parent
210            .get(parent)
211            .map(|subs| subs.iter().map(|s| s.as_str()).collect())
212            .unwrap_or_default()
213    }
214
215    /// Get all companies in the group.
216    pub fn get_all_companies(&self) -> Vec<&str> {
217        let mut companies: Vec<&str> = vec![self.ultimate_parent.as_str()];
218        for rel in &self.relationships {
219            if !companies.contains(&rel.subsidiary_company.as_str()) {
220                companies.push(rel.subsidiary_company.as_str());
221            }
222        }
223        companies
224    }
225
226    /// Get effective ownership percentage from ultimate parent.
227    pub fn get_effective_ownership(&self, company: &str) -> Decimal {
228        if company == self.ultimate_parent {
229            Decimal::from(100)
230        } else {
231            self.effective_ownership
232                .get(company)
233                .copied()
234                .unwrap_or(Decimal::ZERO)
235        }
236    }
237
238    /// Check if two companies are related (share a common parent).
239    pub fn are_related(&self, company1: &str, company2: &str) -> bool {
240        if company1 == company2 {
241            return true;
242        }
243        // Both are in the same group if they have effective ownership
244        let has1 =
245            company1 == self.ultimate_parent || self.effective_ownership.contains_key(company1);
246        let has2 =
247            company2 == self.ultimate_parent || self.effective_ownership.contains_key(company2);
248        has1 && has2
249    }
250
251    /// Get the consolidation method for a company.
252    pub fn get_consolidation_method(&self, company: &str) -> Option<ConsolidationMethod> {
253        self.relationships
254            .iter()
255            .find(|r| r.subsidiary_company == company)
256            .map(|r| r.consolidation_method)
257    }
258
259    /// Calculate effective ownership percentages through the chain.
260    fn calculate_effective_ownership(&mut self) {
261        self.effective_ownership.clear();
262
263        // Start from ultimate parent's direct subsidiaries
264        let mut to_process: Vec<(String, Decimal)> = self
265            .get_direct_subsidiaries(&self.ultimate_parent)
266            .iter()
267            .filter_map(|sub| {
268                self.relationships
269                    .iter()
270                    .find(|r| {
271                        r.parent_company == self.ultimate_parent && r.subsidiary_company == *sub
272                    })
273                    .map(|r| (sub.to_string(), r.ownership_percentage))
274            })
275            .collect();
276
277        // Process in order, calculating effective ownership
278        while let Some((company, effective_pct)) = to_process.pop() {
279            self.effective_ownership
280                .insert(company.clone(), effective_pct);
281
282            // Add this company's subsidiaries
283            for sub in self.get_direct_subsidiaries(&company) {
284                if let Some(rel) = self
285                    .relationships
286                    .iter()
287                    .find(|r| r.parent_company == company && r.subsidiary_company == sub)
288                {
289                    let sub_effective =
290                        effective_pct * rel.ownership_percentage / Decimal::from(100);
291                    to_process.push((sub.to_string(), sub_effective));
292                }
293            }
294        }
295    }
296
297    /// Get relationships that are active on a given date.
298    pub fn get_active_relationships(&self, date: NaiveDate) -> Vec<&IntercompanyRelationship> {
299        self.relationships
300            .iter()
301            .filter(|r| r.is_active_on(date))
302            .collect()
303    }
304
305    /// Get companies that require full consolidation.
306    pub fn get_fully_consolidated_companies(&self) -> Vec<&str> {
307        self.relationships
308            .iter()
309            .filter(|r| r.consolidation_method == ConsolidationMethod::Full)
310            .map(|r| r.subsidiary_company.as_str())
311            .collect()
312    }
313
314    /// Get companies accounted for under equity method.
315    pub fn get_equity_method_companies(&self) -> Vec<&str> {
316        self.relationships
317            .iter()
318            .filter(|r| r.consolidation_method == ConsolidationMethod::Equity)
319            .map(|r| r.subsidiary_company.as_str())
320            .collect()
321    }
322}
323
324/// Intercompany account mapping for a relationship.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct IntercompanyAccountMapping {
327    /// The relationship this mapping applies to.
328    pub relationship_id: String,
329    /// IC Receivable account (seller side).
330    pub ic_receivable_account: String,
331    /// IC Payable account (buyer side).
332    pub ic_payable_account: String,
333    /// IC Revenue account (seller side).
334    pub ic_revenue_account: String,
335    /// IC Expense account (buyer side).
336    pub ic_expense_account: String,
337    /// IC Investment account (parent, for equity method).
338    pub ic_investment_account: Option<String>,
339    /// IC Equity account (subsidiary, for eliminations).
340    pub ic_equity_account: Option<String>,
341}
342
343impl IntercompanyAccountMapping {
344    /// Create a new IC account mapping with standard accounts.
345    pub fn new_standard(relationship_id: String, company_code: &str) -> Self {
346        Self {
347            relationship_id,
348            ic_receivable_account: format!("1310{}", company_code),
349            ic_payable_account: format!("2110{}", company_code),
350            ic_revenue_account: format!("4100{}", company_code),
351            ic_expense_account: format!("5100{}", company_code),
352            ic_investment_account: Some(format!("1510{}", company_code)),
353            ic_equity_account: Some(format!("3100{}", company_code)),
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use rust_decimal_macros::dec;
362
363    #[test]
364    fn test_consolidation_method_from_ownership() {
365        assert_eq!(
366            ConsolidationMethod::from_ownership(dec!(100)),
367            ConsolidationMethod::Full
368        );
369        assert_eq!(
370            ConsolidationMethod::from_ownership(dec!(51)),
371            ConsolidationMethod::Full
372        );
373        assert_eq!(
374            ConsolidationMethod::from_ownership(dec!(50)),
375            ConsolidationMethod::Equity
376        );
377        assert_eq!(
378            ConsolidationMethod::from_ownership(dec!(20)),
379            ConsolidationMethod::Equity
380        );
381        assert_eq!(
382            ConsolidationMethod::from_ownership(dec!(19)),
383            ConsolidationMethod::Cost
384        );
385    }
386
387    #[test]
388    fn test_relationship_is_controlling() {
389        let rel = IntercompanyRelationship::new(
390            "REL001".to_string(),
391            "1000".to_string(),
392            "1100".to_string(),
393            dec!(100),
394            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
395        );
396        assert!(rel.is_controlling());
397
398        let rel2 = IntercompanyRelationship::new(
399            "REL002".to_string(),
400            "1000".to_string(),
401            "2000".to_string(),
402            dec!(30),
403            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
404        );
405        assert!(!rel2.is_controlling());
406        assert!(rel2.has_significant_influence());
407    }
408
409    #[test]
410    fn test_ownership_structure() {
411        let mut structure = OwnershipStructure::new("1000".to_string());
412
413        structure.add_relationship(IntercompanyRelationship::new(
414            "REL001".to_string(),
415            "1000".to_string(),
416            "1100".to_string(),
417            dec!(100),
418            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
419        ));
420
421        structure.add_relationship(IntercompanyRelationship::new(
422            "REL002".to_string(),
423            "1100".to_string(),
424            "1110".to_string(),
425            dec!(80),
426            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
427        ));
428
429        assert_eq!(structure.get_effective_ownership("1000"), dec!(100));
430        assert_eq!(structure.get_effective_ownership("1100"), dec!(100));
431        assert_eq!(structure.get_effective_ownership("1110"), dec!(80));
432
433        assert!(structure.are_related("1000", "1100"));
434        assert!(structure.are_related("1100", "1110"));
435
436        let subs = structure.get_direct_subsidiaries("1000");
437        assert_eq!(subs, vec!["1100"]);
438    }
439
440    #[test]
441    fn test_relationship_active_date() {
442        let mut rel = IntercompanyRelationship::new(
443            "REL001".to_string(),
444            "1000".to_string(),
445            "1100".to_string(),
446            dec!(100),
447            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
448        );
449        rel.end_date = Some(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap());
450
451        assert!(rel.is_active_on(NaiveDate::from_ymd_opt(2022, 6, 15).unwrap()));
452        assert!(rel.is_active_on(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()));
453        assert!(!rel.is_active_on(NaiveDate::from_ymd_opt(2021, 12, 31).unwrap()));
454        assert!(!rel.is_active_on(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()));
455    }
456}