Skip to main content

datasynth_core/models/intercompany/
group_structure.rs

1//! Group structure ownership models for consolidated financial reporting.
2//!
3//! This module provides models for capturing parent-subsidiary relationships,
4//! ownership percentages, and consolidation methods. It feeds into ISA 600
5//! (component auditor scope), consolidated financial statements, and NCI
6//! (non-controlling interest) calculations.
7
8use chrono::NaiveDate;
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11
12/// Complete ownership/consolidation structure for a corporate group.
13///
14/// Captures the parent entity and all subsidiaries and associates, with their
15/// respective ownership percentages and consolidation methods.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GroupStructure {
18    /// Code of the ultimate parent entity in the group.
19    pub parent_entity: String,
20    /// Subsidiary relationships (>50% owned or otherwise controlled entities).
21    pub subsidiaries: Vec<SubsidiaryRelationship>,
22    /// Associate relationships (20–50% owned entities, significant influence).
23    pub associates: Vec<AssociateRelationship>,
24}
25
26impl GroupStructure {
27    /// Create a new group structure with the given parent entity.
28    pub fn new(parent_entity: String) -> Self {
29        Self {
30            parent_entity,
31            subsidiaries: Vec::new(),
32            associates: Vec::new(),
33        }
34    }
35
36    /// Add a subsidiary relationship.
37    pub fn add_subsidiary(&mut self, subsidiary: SubsidiaryRelationship) {
38        self.subsidiaries.push(subsidiary);
39    }
40
41    /// Add an associate relationship.
42    pub fn add_associate(&mut self, associate: AssociateRelationship) {
43        self.associates.push(associate);
44    }
45
46    /// Return the total number of entities in the group (parent + subs + associates).
47    pub fn entity_count(&self) -> usize {
48        1 + self.subsidiaries.len() + self.associates.len()
49    }
50}
51
52/// Relationship between the group parent and a subsidiary entity.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SubsidiaryRelationship {
55    /// Entity code of the subsidiary.
56    pub entity_code: String,
57    /// Percentage of shares held by the parent (0–100).
58    pub ownership_percentage: Decimal,
59    /// Percentage of voting rights held by the parent (0–100).
60    pub voting_rights_percentage: Decimal,
61    /// Accounting consolidation method applied to this subsidiary.
62    pub consolidation_method: GroupConsolidationMethod,
63    /// Date the parent acquired control of this subsidiary.
64    pub acquisition_date: Option<NaiveDate>,
65    /// Non-controlling interest percentage (= 100 − ownership_percentage).
66    pub nci_percentage: Decimal,
67    /// Functional currency code of the subsidiary (e.g. "USD", "EUR").
68    pub functional_currency: String,
69}
70
71impl SubsidiaryRelationship {
72    /// Create a fully-owned (100 %) subsidiary with full consolidation.
73    pub fn new_full(entity_code: String, functional_currency: String) -> Self {
74        Self {
75            entity_code,
76            ownership_percentage: Decimal::from(100),
77            voting_rights_percentage: Decimal::from(100),
78            consolidation_method: GroupConsolidationMethod::FullConsolidation,
79            acquisition_date: None,
80            nci_percentage: Decimal::ZERO,
81            functional_currency,
82        }
83    }
84
85    /// Create a subsidiary with a specified ownership percentage.
86    ///
87    /// The consolidation method and NCI are derived automatically from the
88    /// ownership percentage using IFRS 10 / IAS 28 thresholds.
89    pub fn new_with_ownership(
90        entity_code: String,
91        ownership_percentage: Decimal,
92        functional_currency: String,
93        acquisition_date: Option<NaiveDate>,
94    ) -> Self {
95        let consolidation_method = GroupConsolidationMethod::from_ownership(ownership_percentage);
96        let nci_percentage = Decimal::from(100) - ownership_percentage;
97        Self {
98            entity_code,
99            ownership_percentage,
100            voting_rights_percentage: ownership_percentage,
101            consolidation_method,
102            acquisition_date,
103            nci_percentage,
104            functional_currency,
105        }
106    }
107}
108
109/// Consolidation method applied to a subsidiary or investee.
110///
111/// Distinct from the existing [`super::ConsolidationMethod`] in that it uses
112/// IFRS-aligned terminology and adds a `FairValue` option for FVTPL investments.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum GroupConsolidationMethod {
116    /// Full line-by-line consolidation (IFRS 10, >50 % ownership / control).
117    FullConsolidation,
118    /// Equity method (IAS 28, 20–50 % ownership, significant influence).
119    EquityMethod,
120    /// Fair value through profit or loss (<20 % ownership, no influence).
121    FairValue,
122}
123
124impl GroupConsolidationMethod {
125    /// Derive the consolidation method from the ownership percentage.
126    ///
127    /// Uses standard IFRS 10 / IAS 28 thresholds:
128    /// - > 50 % → FullConsolidation
129    /// - 20–50 % → EquityMethod
130    /// - < 20 % → FairValue
131    pub fn from_ownership(ownership_pct: Decimal) -> Self {
132        if ownership_pct > Decimal::from(50) {
133            Self::FullConsolidation
134        } else if ownership_pct >= Decimal::from(20) {
135            Self::EquityMethod
136        } else {
137            Self::FairValue
138        }
139    }
140}
141
142/// Relationship between the group parent and an associate entity.
143///
144/// Associates are accounted for under the equity method (IAS 28).
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct AssociateRelationship {
147    /// Entity code of the associate.
148    pub entity_code: String,
149    /// Percentage of shares held by the investor (typically 20–50 %).
150    pub ownership_percentage: Decimal,
151    /// Share of the associate's profit/(loss) recognised in the period.
152    pub equity_pickup: Decimal,
153}
154
155impl AssociateRelationship {
156    /// Create a new associate relationship with zero equity pickup.
157    pub fn new(entity_code: String, ownership_percentage: Decimal) -> Self {
158        Self {
159            entity_code,
160            ownership_percentage,
161            equity_pickup: Decimal::ZERO,
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// NCI Measurement
168// ---------------------------------------------------------------------------
169
170/// IFRS 3 § 19 / ASC 805-30-30-1 acquisition-date NCI measurement
171/// methods.  Determines how the non-controlling interest is initially
172/// measured at the date of a business combination and, by extension,
173/// how goodwill is measured (full vs partial / proportionate).
174///
175/// **The choice is per-acquisition** — IFRS 3.19 lets the acquirer
176/// elect on a transaction-by-transaction basis.  US GAAP (ASC 805) is
177/// stricter: full-goodwill (fair value) is mandatory.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
179#[serde(rename_all = "snake_case")]
180pub enum NciMeasurementMethod {
181    /// **Proportionate share method** (partial goodwill) — IFRS 3.19(b).
182    /// NCI is measured at its proportionate share of the acquiree's
183    /// **identifiable net assets**.  Goodwill recognised on
184    /// consolidation reflects only the parent's share.  v5.0 default
185    /// — the v5.0 stub effectively applied this method by computing
186    /// `total_nci = nci_share_net_assets`.
187    #[default]
188    Proportionate,
189    /// **Fair value method** (full goodwill) — IFRS 3.19(a) / ASC
190    /// 805-30-30-1.  NCI is measured at its acquisition-date fair
191    /// value (typically determined by reference to the quoted price
192    /// per share or a valuation technique).  Goodwill recognised on
193    /// consolidation includes both the parent's and the NCI's share.
194    /// Required under US GAAP; optional under IFRS.
195    FullGoodwill,
196}
197
198/// Non-controlling interest measurement for a subsidiary.
199///
200/// Captures the NCI share of net assets and current-period profit/loss,
201/// computed from the subsidiary's `nci_percentage` in [`SubsidiaryRelationship`].
202///
203/// v5.2: extended with an explicit `method` field plus an optional
204/// `acquisition_date_fair_value` carrying the IFRS 3 § 19 acquisition-
205/// date measurement.  When `method == FullGoodwill` the
206/// `acquisition_date_fair_value` is the IFRS 3.19(a) NCI fair-value
207/// figure used for total NCI; when `Proportionate` it can still be
208/// recorded for disclosure purposes but does not flow into `total_nci`.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct NciMeasurement {
211    /// Entity code of the subsidiary carrying an NCI.
212    pub entity_code: String,
213    /// NCI percentage (= 100 − parent ownership percentage).
214    #[serde(with = "crate::serde_decimal")]
215    pub nci_percentage: Decimal,
216    /// IFRS 3 § 19 acquisition-date NCI measurement method.  Defaults
217    /// to `Proportionate` (matches the v5.0–v5.1 behaviour of
218    /// `NciMeasurement::compute`).
219    #[serde(default)]
220    pub method: NciMeasurementMethod,
221    /// Acquisition-date NCI fair value per IFRS 3.19(a).  Required to
222    /// be `Some(fv)` when `method == FullGoodwill`; optional otherwise
223    /// (carried for disclosure even under the Proportionate method).
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    #[serde(with = "crate::serde_decimal::option")]
226    pub acquisition_date_fair_value: Option<Decimal>,
227    /// NCI share of the subsidiary's net assets at period-end.
228    #[serde(with = "crate::serde_decimal")]
229    pub nci_share_net_assets: Decimal,
230    /// NCI share of the subsidiary's net income/(loss) for the period.
231    #[serde(with = "crate::serde_decimal")]
232    pub nci_share_profit: Decimal,
233    /// Total NCI recognised in the consolidated balance sheet.  Under
234    /// `Proportionate` this equals `nci_share_net_assets`; under
235    /// `FullGoodwill` this equals
236    /// `acquisition_date_fair_value + Σ(nci_share_profit − nci_dividends)`
237    /// — the acquisition-date fair value rolled forward.  v5.2: full-
238    /// goodwill rollforward is computed by callers passing prior-period
239    /// activity into [`Self::compute_with_method`]; the NCI rollforward
240    /// in `datasynth-group::aggregate::nci` already handles the
241    /// period-by-period activity.
242    #[serde(with = "crate::serde_decimal")]
243    pub total_nci: Decimal,
244}
245
246impl NciMeasurement {
247    /// Compute NCI measurement using the v5.0 simplified path —
248    /// `Proportionate` method, no acquisition-date fair value
249    /// recorded.  Equivalent to
250    /// `compute_with_method(.., Proportionate, None, ..)`.
251    ///
252    /// # Arguments
253    /// * `entity_code` — entity code of the subsidiary.
254    /// * `nci_percentage` — NCI percentage (0–100).
255    /// * `net_assets` — subsidiary net assets at period-end (before NCI split).
256    /// * `net_income` — subsidiary net income/(loss) for the period.
257    pub fn compute(
258        entity_code: String,
259        nci_percentage: Decimal,
260        net_assets: Decimal,
261        net_income: Decimal,
262    ) -> Self {
263        Self::compute_with_method(
264            entity_code,
265            nci_percentage,
266            net_assets,
267            net_income,
268            NciMeasurementMethod::Proportionate,
269            None,
270        )
271    }
272
273    /// Compute NCI measurement with an explicit IFRS 3 § 19 method
274    /// and (for the full-goodwill path) the acquisition-date fair
275    /// value.
276    ///
277    /// # Arguments
278    /// * `entity_code` — entity code of the subsidiary.
279    /// * `nci_percentage` — NCI percentage (0–100).
280    /// * `net_assets` — subsidiary net assets at period-end (before NCI split).
281    /// * `net_income` — subsidiary net income/(loss) for the period.
282    /// * `method` — IFRS 3.19 measurement choice.
283    /// * `acquisition_date_fair_value` — required when
284    ///   `method == FullGoodwill`; ignored otherwise (other than
285    ///   being recorded for disclosure).  Pass `None` for proportionate
286    ///   method when no fair-value disclosure is needed.
287    ///
288    /// Under `Proportionate`: `total_nci = nci_share_net_assets`.
289    /// Under `FullGoodwill`: `total_nci = acquisition_date_fair_value`
290    /// (caller's responsibility to roll forward across periods using
291    /// the [`crate::aggregate::nci::compute_nci_rollforward`] path in
292    /// `datasynth-group`).  When `FullGoodwill` is requested without
293    /// a fair value, falls back to the proportionate calculation and
294    /// emits a `tracing::warn!`.
295    pub fn compute_with_method(
296        entity_code: String,
297        nci_percentage: Decimal,
298        net_assets: Decimal,
299        net_income: Decimal,
300        method: NciMeasurementMethod,
301        acquisition_date_fair_value: Option<Decimal>,
302    ) -> Self {
303        let hundred = Decimal::from(100);
304        let nci_pct_fraction = nci_percentage / hundred;
305        let nci_share_net_assets = net_assets * nci_pct_fraction;
306        let nci_share_profit = net_income * nci_pct_fraction;
307
308        let total_nci = match (method, acquisition_date_fair_value) {
309            (NciMeasurementMethod::FullGoodwill, Some(fv)) => fv,
310            (NciMeasurementMethod::FullGoodwill, None) => {
311                // Caller asked for full-goodwill but didn't supply a
312                // fair value — fall back to proportionate so the
313                // computation still produces a balanced number.
314                tracing::warn!(
315                    entity_code = %entity_code,
316                    "NciMeasurement::compute_with_method: \
317                     FullGoodwill method requested without an \
318                     acquisition_date_fair_value — falling back to \
319                     proportionate computation",
320                );
321                nci_share_net_assets
322            }
323            (NciMeasurementMethod::Proportionate, _) => nci_share_net_assets,
324        };
325
326        Self {
327            entity_code,
328            nci_percentage,
329            method,
330            acquisition_date_fair_value,
331            nci_share_net_assets,
332            nci_share_profit,
333            total_nci,
334        }
335    }
336}
337
338#[cfg(test)]
339#[allow(clippy::unwrap_used)]
340mod tests {
341    use super::*;
342    use rust_decimal_macros::dec;
343
344    #[test]
345    fn test_group_consolidation_method_from_ownership() {
346        assert_eq!(
347            GroupConsolidationMethod::from_ownership(dec!(100)),
348            GroupConsolidationMethod::FullConsolidation
349        );
350        assert_eq!(
351            GroupConsolidationMethod::from_ownership(dec!(51)),
352            GroupConsolidationMethod::FullConsolidation
353        );
354        assert_eq!(
355            GroupConsolidationMethod::from_ownership(dec!(50)),
356            GroupConsolidationMethod::EquityMethod
357        );
358        assert_eq!(
359            GroupConsolidationMethod::from_ownership(dec!(20)),
360            GroupConsolidationMethod::EquityMethod
361        );
362        assert_eq!(
363            GroupConsolidationMethod::from_ownership(dec!(19)),
364            GroupConsolidationMethod::FairValue
365        );
366        assert_eq!(
367            GroupConsolidationMethod::from_ownership(dec!(0)),
368            GroupConsolidationMethod::FairValue
369        );
370    }
371
372    #[test]
373    fn nci_compute_legacy_path_uses_proportionate() {
374        // The v5.0 `compute()` shortcut must continue to behave as
375        // before (proportionate share of net assets).  Net assets =
376        // 1_000_000, nci_percentage = 25 → 25% × 1M = 250k.
377        let m =
378            NciMeasurement::compute("SUB1".to_string(), dec!(25), dec!(1_000_000), dec!(120_000));
379        assert_eq!(m.method, NciMeasurementMethod::Proportionate);
380        assert_eq!(m.nci_share_net_assets, dec!(250_000));
381        assert_eq!(m.nci_share_profit, dec!(30_000));
382        assert_eq!(m.total_nci, dec!(250_000));
383        assert!(m.acquisition_date_fair_value.is_none());
384    }
385
386    #[test]
387    fn nci_compute_full_goodwill_uses_acquisition_date_fair_value() {
388        // IFRS 3.19(a): NCI is measured at its acquisition-date fair
389        // value.  Total NCI = the supplied fair value (310k); the
390        // proportionate net-asset figure (250k) becomes the bookkeeping
391        // floor for share-of-net-assets disclosures but does NOT drive
392        // total_nci.  The "extra 60k" represents the NCI's share of
393        // goodwill recognised on consolidation.
394        let m = NciMeasurement::compute_with_method(
395            "SUB1".to_string(),
396            dec!(25),
397            dec!(1_000_000),
398            dec!(120_000),
399            NciMeasurementMethod::FullGoodwill,
400            Some(dec!(310_000)),
401        );
402        assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
403        assert_eq!(m.acquisition_date_fair_value, Some(dec!(310_000)));
404        assert_eq!(m.nci_share_net_assets, dec!(250_000));
405        assert_eq!(m.total_nci, dec!(310_000), "full-goodwill total NCI = FV");
406    }
407
408    #[test]
409    fn nci_compute_full_goodwill_without_fair_value_falls_back() {
410        // Caller asks for FullGoodwill but doesn't supply a fair
411        // value.  Must NOT panic — fall back to the proportionate
412        // calculation and emit a warning.  Total = nci_share_net_assets.
413        let m = NciMeasurement::compute_with_method(
414            "SUB1".to_string(),
415            dec!(40),
416            dec!(500_000),
417            dec!(50_000),
418            NciMeasurementMethod::FullGoodwill,
419            None,
420        );
421        assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
422        assert!(m.acquisition_date_fair_value.is_none());
423        assert_eq!(
424            m.total_nci,
425            dec!(200_000),
426            "missing FV must fall back to proportionate (40% of 500k)"
427        );
428    }
429
430    #[test]
431    fn nci_compute_proportionate_with_disclosure_fair_value() {
432        // Proportionate method but caller wants to record a FV for
433        // disclosure (IFRS 3.19 lets the entity choose; some entities
434        // disclose the FV they would have used had they elected the
435        // alternative method).  total_nci stays proportionate; the FV
436        // is preserved for serialisation.
437        let m = NciMeasurement::compute_with_method(
438            "SUB1".to_string(),
439            dec!(30),
440            dec!(800_000),
441            dec!(100_000),
442            NciMeasurementMethod::Proportionate,
443            Some(dec!(280_000)),
444        );
445        assert_eq!(m.total_nci, dec!(240_000), "proportionate: 30% of 800k");
446        assert_eq!(m.acquisition_date_fair_value, Some(dec!(280_000)));
447    }
448}