Skip to main content

datasynth_core/models/intercompany/
ownership_change.rs

1//! Ownership-change events under IFRS 3.42 / IFRS 10.B97 — step
2//! acquisitions, partial divestitures, and control-loss
3//! deconsolidations.
4//!
5//! v5.0 / v5.1 assumed steady-state ownership: each subsidiary kept
6//! the same `ownership_percent` for the whole engagement period, so
7//! the consolidation rollforward could attribute profit/OCI/dividends
8//! pro-rata once and call it done.  Real engagements often see
9//! ownership change mid-period.  v5.2 introduces the accounting-side
10//! event model — this module — leaving the rollforward / driver
11//! wiring to a follow-up PR.
12//!
13//! # Standards reference
14//!
15//! - **IFRS 3 § 41–42** — When an entity obtains control over an
16//!   investee in which it previously held a non-controlling interest
17//!   (associate, joint venture, or financial-asset position), the
18//!   **previously-held interest is re-measured at its acquisition-
19//!   date fair value** and the resulting gain or loss is recognised
20//!   in profit or loss.
21//! - **IFRS 10 § 23** / **IFRS 10.B96** — Changes in a parent's
22//!   ownership interest that **do not result in a loss of control**
23//!   are accounted for as equity transactions.  No gain or loss is
24//!   recognised in profit or loss; the carrying amounts of the
25//!   controlling and non-controlling interests are adjusted to
26//!   reflect the new relative interests.
27//! - **IFRS 10 § 25** / **IFRS 10.B97** — When a parent loses control
28//!   of a subsidiary, the parent **derecognises** the subsidiary's
29//!   assets and liabilities, **recognises any retained interest at
30//!   fair value**, and recognises the resulting gain or loss in
31//!   profit or loss.
32//! - **ASC 805-10-25-10** / **ASC 810-10-40-4** — US GAAP
33//!   equivalents.  Same treatment for control-gained (re-measure
34//!   prior interest at FV with P&L gain/loss) and control-lost
35//!   (FV the retained interest, recognise gain/loss).
36//!
37//! # Scope
38//!
39//! This module ships the **typed event model + arithmetic helpers**.
40//! The downstream wiring — extending `compute_nci_rollforward` to
41//! consume these events and reflect them in the period's NCI
42//! roll-forward — is on the v5.2 follow-up roadmap and tracked in
43//! the README.
44
45use chrono::NaiveDate;
46use rust_decimal::Decimal;
47use serde::{Deserialize, Serialize};
48
49use super::group_structure::NciMeasurementMethod;
50
51/// Classification of an ownership-change event.  Each variant maps to
52/// a different IFRS 10 / IFRS 3 accounting treatment.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum OwnershipChangeType {
56    /// Control gained from a non-control position (associate, JV, or
57    /// FV investment).  IFRS 3.42 / ASC 805-10-25-10: the
58    /// previously-held interest is re-measured at fair value with the
59    /// gain/loss in P&L; the acquiree is consolidated thereafter.
60    ControlGained,
61    /// Existing controlling stake increased — additional shares
62    /// purchased without changing the consolidation method
63    /// (e.g. 60% → 80%).  IFRS 10.23 / 10.B96 equity-transaction
64    /// treatment: NCI shrinks, no P&L gain/loss.
65    ControlIncreased,
66    /// Existing controlling stake decreased while still retaining
67    /// control (e.g. 80% → 55%).  IFRS 10.23 / 10.B96 equity-
68    /// transaction treatment: NCI grows, no P&L gain/loss.
69    ControlDecreased,
70    /// Control lost — full deconsolidation under IFRS 10.25 /
71    /// IFRS 10.B97.  Subsidiary's assets and liabilities are
72    /// derecognised, any retained interest is re-measured at fair
73    /// value, and the gain/loss flows to P&L.
74    ControlLost,
75}
76
77/// One mid-period ownership-change event for a subsidiary or
78/// associate.  All amounts are in the group presentation currency
79/// (translation per IAS 21 must already have been applied — same
80/// contract as `NciInputs`).
81///
82/// The struct captures **all** the inputs an IFRS 3.42 / IFRS 10.B97
83/// computation needs; the helper methods derive the gain/loss and
84/// the post-event NCI carrying amount.  v5.2 ships the model + the
85/// helpers; the rollforward wiring is a follow-up.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct OwnershipChangeEvent {
88    /// Entity code of the subsidiary / associate whose ownership
89    /// changed.  Joins to `ManifestEntity::code` so the rollforward
90    /// can attribute the event to the right entity.
91    pub entity_code: String,
92
93    /// Code of the parent entity whose interest changed.  Mirrors
94    /// `ManifestEntity::parent_code`.
95    pub parent_entity_code: String,
96
97    /// What happened — drives which accounting treatment applies.
98    pub event_type: OwnershipChangeType,
99
100    /// Date the change took effect (typically the closing date of
101    /// the share purchase / sale agreement).
102    pub effective_date: NaiveDate,
103
104    /// Parent's ownership percent **immediately before** the event,
105    /// in `[0, 1]`.  Zero for a fresh acquisition where the parent
106    /// had no prior position; one only for a 100 %-owned subsidiary
107    /// that's being sold (rare).
108    #[serde(with = "crate::serde_decimal")]
109    pub ownership_percent_before: Decimal,
110
111    /// Parent's ownership percent **immediately after** the event,
112    /// in `[0, 1]`.  Zero only when control is fully lost AND no
113    /// retained interest remains; one when this is a roll-up to
114    /// 100 %.
115    #[serde(with = "crate::serde_decimal")]
116    pub ownership_percent_after: Decimal,
117
118    /// Carrying amount of the **previously-held interest** in the
119    /// investor's books immediately before the event, in the group
120    /// presentation currency.  For `ControlGained`: typically the
121    /// equity-method carrying value of the associate.  For
122    /// `ControlLost`: the carrying amount of the retained interest
123    /// post-deconsolidation (zero if no retained interest).
124    #[serde(default, with = "crate::serde_decimal::option")]
125    pub previously_held_interest_carrying: Option<Decimal>,
126
127    /// **Acquisition-date fair value** of the previously-held
128    /// interest (`ControlGained`) or the retained interest
129    /// (`ControlLost`).  This is the amount the investor re-measures
130    /// the prior position to under IFRS 3.42 / IFRS 10.B97 — the
131    /// difference between this and `previously_held_interest_carrying`
132    /// is the P&L gain or loss.
133    #[serde(default, with = "crate::serde_decimal::option")]
134    pub previously_held_interest_fair_value: Option<Decimal>,
135
136    /// Cash / share consideration paid (positive on
137    /// `ControlGained` / `ControlIncreased`) or received (negative
138    /// on `ControlDecreased` / `ControlLost`).  Sign convention:
139    /// **positive = outflow from parent**, matching how the cash
140    /// flow statement presents acquisitions.
141    #[serde(with = "crate::serde_decimal")]
142    pub consideration_paid_or_received: Decimal,
143
144    /// IFRS 3 § 19 acquisition-date NCI fair value when this event
145    /// triggers a new consolidation (`ControlGained` only — must be
146    /// `Some(fv)` when method is `FullGoodwill`; ignored for the
147    /// other event types).  Mirrors the field on
148    /// [`crate::models::business_combination::BusinessCombination`].
149    #[serde(default, with = "crate::serde_decimal::option")]
150    pub acquisition_date_nci_fair_value: Option<Decimal>,
151
152    /// Method used to measure the new NCI (only relevant for
153    /// `ControlGained`).  Defaults to `Proportionate`.
154    #[serde(default)]
155    pub nci_measurement_method: NciMeasurementMethod,
156
157    /// Group presentation currency the amounts are denominated in.
158    pub currency: String,
159}
160
161impl OwnershipChangeEvent {
162    /// Compute the **P&L gain or loss** triggered by an IFRS 3.42 /
163    /// IFRS 10.B97 re-measurement.  Returns:
164    ///
165    /// - `Some(gain)` (positive) when the fair value of the prior
166    ///   position exceeds its carrying amount on `ControlGained` or
167    ///   `ControlLost`.
168    /// - `Some(loss)` (negative) when carrying exceeds fair value.
169    /// - `None` when the event is `ControlIncreased` or
170    ///   `ControlDecreased` (IFRS 10.23 — no gain/loss in P&L for
171    ///   equity-transaction changes), OR when either the carrying
172    ///   amount or the fair value is missing.
173    pub fn p_and_l_gain_or_loss(&self) -> Option<Decimal> {
174        match self.event_type {
175            OwnershipChangeType::ControlGained | OwnershipChangeType::ControlLost => {
176                let carrying = self.previously_held_interest_carrying?;
177                let fv = self.previously_held_interest_fair_value?;
178                Some(fv - carrying)
179            }
180            OwnershipChangeType::ControlIncreased | OwnershipChangeType::ControlDecreased => None,
181        }
182    }
183
184    /// Returns `true` when this event triggers a re-measurement
185    /// gain/loss in P&L (IFRS 3.42 / IFRS 10.B97).  False for
186    /// equity-transaction changes (IFRS 10.23) where the entire
187    /// adjustment runs through equity.
188    pub fn triggers_pl_remeasurement(&self) -> bool {
189        matches!(
190            self.event_type,
191            OwnershipChangeType::ControlGained | OwnershipChangeType::ControlLost
192        )
193    }
194
195    /// Returns `true` when this event triggers a transition between
196    /// consolidation methods (e.g. associate → subsidiary or
197    /// subsidiary → associate).  False for ownership changes that
198    /// stay within full-consolidation scope.
199    pub fn triggers_method_transition(&self) -> bool {
200        matches!(
201            self.event_type,
202            OwnershipChangeType::ControlGained | OwnershipChangeType::ControlLost
203        )
204    }
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used)]
209mod tests {
210    use super::*;
211    use rust_decimal_macros::dec;
212
213    fn date() -> NaiveDate {
214        NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()
215    }
216
217    fn control_gained_sample() -> OwnershipChangeEvent {
218        // Step acquisition: parent went from 30% (associate) to 80%
219        // (full sub).  Prior associate carrying = 100k; fair value at
220        // acquisition = 130k → 30k IFRS 3.42 gain in P&L.
221        OwnershipChangeEvent {
222            entity_code: "SUB1".to_string(),
223            parent_entity_code: "PARENT".to_string(),
224            event_type: OwnershipChangeType::ControlGained,
225            effective_date: date(),
226            ownership_percent_before: dec!(0.30),
227            ownership_percent_after: dec!(0.80),
228            previously_held_interest_carrying: Some(dec!(100_000)),
229            previously_held_interest_fair_value: Some(dec!(130_000)),
230            consideration_paid_or_received: dec!(900_000),
231            acquisition_date_nci_fair_value: Some(dec!(310_000)),
232            nci_measurement_method: NciMeasurementMethod::FullGoodwill,
233            currency: "EUR".to_string(),
234        }
235    }
236
237    #[test]
238    fn control_gained_recognises_remeasurement_gain() {
239        let ev = control_gained_sample();
240        assert_eq!(
241            ev.p_and_l_gain_or_loss(),
242            Some(dec!(30_000)),
243            "IFRS 3.42 gain = FV (130k) − carrying (100k)"
244        );
245        assert!(ev.triggers_pl_remeasurement());
246        assert!(ev.triggers_method_transition());
247    }
248
249    #[test]
250    fn control_lost_with_loss_in_p_and_l() {
251        // Parent sold from 80% to 30%; retained 30% interest.  Carrying
252        // of the retained interest in old basis = 250k; fair value =
253        // 200k → 50k loss in P&L per IFRS 10.B97.
254        let ev = OwnershipChangeEvent {
255            entity_code: "SUB1".to_string(),
256            parent_entity_code: "PARENT".to_string(),
257            event_type: OwnershipChangeType::ControlLost,
258            effective_date: date(),
259            ownership_percent_before: dec!(0.80),
260            ownership_percent_after: dec!(0.30),
261            previously_held_interest_carrying: Some(dec!(250_000)),
262            previously_held_interest_fair_value: Some(dec!(200_000)),
263            consideration_paid_or_received: dec!(-600_000), // cash received
264            acquisition_date_nci_fair_value: None,
265            nci_measurement_method: NciMeasurementMethod::Proportionate,
266            currency: "EUR".to_string(),
267        };
268        assert_eq!(ev.p_and_l_gain_or_loss(), Some(dec!(-50_000)));
269        assert!(ev.triggers_pl_remeasurement());
270        assert!(ev.triggers_method_transition());
271    }
272
273    #[test]
274    fn control_increased_is_equity_transaction_no_p_and_l() {
275        // 60% → 80%: equity transaction.  No P&L gain/loss, no method
276        // transition.
277        let ev = OwnershipChangeEvent {
278            entity_code: "SUB1".to_string(),
279            parent_entity_code: "PARENT".to_string(),
280            event_type: OwnershipChangeType::ControlIncreased,
281            effective_date: date(),
282            ownership_percent_before: dec!(0.60),
283            ownership_percent_after: dec!(0.80),
284            previously_held_interest_carrying: Some(dec!(500_000)),
285            previously_held_interest_fair_value: Some(dec!(550_000)),
286            consideration_paid_or_received: dec!(200_000),
287            acquisition_date_nci_fair_value: None,
288            nci_measurement_method: NciMeasurementMethod::Proportionate,
289            currency: "EUR".to_string(),
290        };
291        assert_eq!(
292            ev.p_and_l_gain_or_loss(),
293            None,
294            "IFRS 10.23 — equity transaction, no P&L gain/loss"
295        );
296        assert!(!ev.triggers_pl_remeasurement());
297        assert!(!ev.triggers_method_transition());
298    }
299
300    #[test]
301    fn control_decreased_is_equity_transaction_no_p_and_l() {
302        let ev = OwnershipChangeEvent {
303            entity_code: "SUB1".to_string(),
304            parent_entity_code: "PARENT".to_string(),
305            event_type: OwnershipChangeType::ControlDecreased,
306            effective_date: date(),
307            ownership_percent_before: dec!(0.80),
308            ownership_percent_after: dec!(0.55),
309            previously_held_interest_carrying: Some(dec!(800_000)),
310            previously_held_interest_fair_value: Some(dec!(700_000)),
311            consideration_paid_or_received: dec!(-300_000),
312            acquisition_date_nci_fair_value: None,
313            nci_measurement_method: NciMeasurementMethod::Proportionate,
314            currency: "EUR".to_string(),
315        };
316        assert_eq!(ev.p_and_l_gain_or_loss(), None);
317        assert!(!ev.triggers_pl_remeasurement());
318        assert!(!ev.triggers_method_transition());
319    }
320
321    #[test]
322    fn p_and_l_returns_none_when_carrying_or_fv_missing() {
323        // Even ControlGained returns None when inputs are incomplete —
324        // the caller should not interpret missing data as zero gain.
325        let mut ev = control_gained_sample();
326        ev.previously_held_interest_carrying = None;
327        assert_eq!(ev.p_and_l_gain_or_loss(), None);
328
329        let mut ev = control_gained_sample();
330        ev.previously_held_interest_fair_value = None;
331        assert_eq!(ev.p_and_l_gain_or_loss(), None);
332    }
333
334    #[test]
335    fn ownership_change_event_round_trips_via_serde() {
336        let ev = control_gained_sample();
337        let json = serde_json::to_string(&ev).unwrap();
338        let back: OwnershipChangeEvent = serde_json::from_str(&json).unwrap();
339
340        assert_eq!(back.entity_code, "SUB1");
341        assert_eq!(back.event_type, OwnershipChangeType::ControlGained);
342        assert_eq!(back.previously_held_interest_carrying, Some(dec!(100_000)));
343        assert_eq!(
344            back.previously_held_interest_fair_value,
345            Some(dec!(130_000))
346        );
347        assert_eq!(back.acquisition_date_nci_fair_value, Some(dec!(310_000)));
348        assert_eq!(
349            back.nci_measurement_method,
350            NciMeasurementMethod::FullGoodwill
351        );
352    }
353}