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}