Skip to main content

datasynth_core/models/
cgu.rs

1//! Cash-generating units (CGUs) and CGU-level goodwill impairment
2//! testing under IAS 36.
3//!
4//! v5.0 / v5.1 had a per-asset [`datasynth_standards::ImpairmentTest`]
5//! that handled individual goodwill or PP&E assets, but no
6//! **CGU-level** goodwill impairment test.  In group consolidation,
7//! goodwill recognised on a business combination is allocated to one
8//! or more CGUs at the acquisition date and tested for impairment
9//! annually (IAS 36.10) at the CGU level — not at the individual-
10//! goodwill-asset level.  This module fills that gap.
11//!
12//! # Standards reference
13//!
14//! - **IAS 36 § 6** — A cash-generating unit is the **smallest
15//!   identifiable group of assets that generates cash inflows that
16//!   are largely independent of the cash inflows from other assets
17//!   or groups of assets**.
18//! - **IAS 36 § 10** — Goodwill must be tested for impairment **at
19//!   least annually** (and whenever there is an indication of
20//!   impairment), regardless of whether indicators exist.
21//! - **IAS 36 § 80** — Goodwill acquired in a business combination
22//!   shall be allocated to each of the acquirer's CGUs (or groups of
23//!   CGUs) that is expected to benefit from the synergies.
24//! - **IAS 36 § 18** — The recoverable amount of an asset (or CGU)
25//!   is the higher of its **fair value less costs of disposal**
26//!   and its **value in use**.
27//! - **IAS 36 § 104** — When a CGU's recoverable amount is less than
28//!   its carrying amount, the impairment loss is allocated:
29//!   1. First, to **goodwill** allocated to the CGU.
30//!   2. Then, **pro rata** to the other assets of the CGU based on
31//!      the carrying amount of each asset.
32//! - **IAS 36 § 124** — An impairment loss recognised for **goodwill
33//!   shall not be reversed** in a subsequent period.
34//!
35//! # Scope
36//!
37//! v5.2 ships the typed model + arithmetic helpers
38//! ([`CashGeneratingUnit`], [`GoodwillAllocation`], [`CguImpairmentTest`],
39//! [`CguImpairmentResult`]).  The CGU identification step (mapping
40//! manifest entities → CGUs based on operating segments / cash-flow
41//! independence) and the auto-test trigger at engagement period-end
42//! are downstream wiring and tracked as v5.2 follow-ups.
43
44use chrono::NaiveDate;
45use rust_decimal::Decimal;
46use serde::{Deserialize, Serialize};
47
48/// A cash-generating unit per IAS 36 § 6 — the smallest identifiable
49/// group of assets that generates largely independent cash inflows.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct CashGeneratingUnit {
52    /// Unique CGU identifier — typically a stable code so allocations
53    /// can be tracked across periods.
54    pub cgu_id: String,
55
56    /// Human-readable name (e.g. "EMEA Consumer Products").
57    pub name: String,
58
59    /// Codes of the entities (subsidiaries / branches) whose cash
60    /// flows are aggregated to form this CGU.  A CGU may span multiple
61    /// legal entities or be a sub-division of a single entity.
62    pub member_entity_codes: Vec<String>,
63
64    /// Reportable segment this CGU rolls up to (IFRS 8 / ASC 280).
65    /// Multiple CGUs can map to the same segment.  None when no
66    /// segment-reporting attribution applies.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub segment_code: Option<String>,
69}
70
71impl CashGeneratingUnit {
72    /// Construct a CGU.  `member_entity_codes` may be empty if the
73    /// CGU is being seeded for later allocation; downstream tests
74    /// require at least one member.
75    pub fn new(
76        cgu_id: impl Into<String>,
77        name: impl Into<String>,
78        member_entity_codes: Vec<String>,
79    ) -> Self {
80        Self {
81            cgu_id: cgu_id.into(),
82            name: name.into(),
83            member_entity_codes,
84            segment_code: None,
85        }
86    }
87
88    /// Attach a reportable segment code (IFRS 8 / ASC 280).
89    pub fn with_segment(mut self, segment_code: impl Into<String>) -> Self {
90        self.segment_code = Some(segment_code.into());
91        self
92    }
93}
94
95/// Goodwill allocated to a CGU at the acquisition date per IAS 36 §
96/// 80.  When a single business combination's goodwill spans multiple
97/// CGUs, the engagement records one [`GoodwillAllocation`] per CGU
98/// totalling the goodwill on the acquisition.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct GoodwillAllocation {
101    /// CGU identifier (matches [`CashGeneratingUnit::cgu_id`]).
102    pub cgu_id: String,
103
104    /// Business combination identifier (matches
105    /// [`crate::models::business_combination::BusinessCombination::id`])
106    /// — links the goodwill to the underlying acquisition for audit
107    /// trail and post-implementation review.
108    pub business_combination_id: String,
109
110    /// Amount of goodwill allocated, in the group presentation
111    /// currency.  Always non-negative — a bargain purchase produces
112    /// no goodwill, hence no allocation row.
113    #[serde(with = "crate::serde_decimal")]
114    pub goodwill_amount: Decimal,
115
116    /// Date the allocation took effect (typically the acquisition
117    /// date).
118    pub allocation_date: NaiveDate,
119}
120
121/// Annual CGU-level goodwill impairment test under IAS 36 § 10.
122///
123/// Inputs to the test are the CGU's carrying amount (including
124/// allocated goodwill + the other assets of the unit), and the
125/// recoverable amount components — fair value less costs of disposal
126/// and value in use.  IAS 36 § 18 says recoverable = max of the two.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct CguImpairmentTest {
129    /// CGU being tested.
130    pub cgu_id: String,
131
132    /// Period-end test date (IAS 36 § 10 — at least annually).
133    pub test_date: NaiveDate,
134
135    /// Total goodwill allocated to this CGU at test date.  Always
136    /// non-negative.  Sum of the
137    /// [`GoodwillAllocation::goodwill_amount`] entries that land
138    /// on this CGU, net of prior-period impairment that reduced the
139    /// allocated goodwill (IAS 36 § 124 prohibits reversal of
140    /// goodwill impairments).
141    #[serde(with = "crate::serde_decimal")]
142    pub allocated_goodwill: Decimal,
143
144    /// Carrying amount of the **other assets** of the CGU
145    /// (everything except the allocated goodwill) immediately
146    /// before this test.  Always non-negative.
147    #[serde(with = "crate::serde_decimal")]
148    pub other_carrying: Decimal,
149
150    /// Fair value of the CGU less costs of disposal at the test
151    /// date.
152    #[serde(with = "crate::serde_decimal")]
153    pub fair_value_less_costs: Decimal,
154
155    /// Value in use of the CGU at the test date (typically the
156    /// present value of the next 5 years of net cash flows + a
157    /// terminal value, discounted at the WACC).
158    #[serde(with = "crate::serde_decimal")]
159    pub value_in_use: Decimal,
160
161    /// Group presentation currency.
162    pub currency: String,
163}
164
165/// Result of a CGU-level goodwill impairment test.  Contains the
166/// recoverable amount, total impairment loss, and the IAS 36 § 104
167/// allocation between goodwill and other assets.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct CguImpairmentResult {
170    /// CGU tested.
171    pub cgu_id: String,
172
173    /// Period-end test date (matches [`CguImpairmentTest::test_date`]).
174    pub test_date: NaiveDate,
175
176    /// Total carrying amount of the CGU before the test
177    /// (`allocated_goodwill + other_carrying`).
178    #[serde(with = "crate::serde_decimal")]
179    pub carrying_total: Decimal,
180
181    /// Recoverable amount = max(fair_value_less_costs, value_in_use)
182    /// per IAS 36 § 18.
183    #[serde(with = "crate::serde_decimal")]
184    pub recoverable_amount: Decimal,
185
186    /// Total impairment loss on the CGU =
187    /// `max(0, carrying_total − recoverable_amount)`.  Zero when the
188    /// CGU is recoverable.
189    #[serde(with = "crate::serde_decimal")]
190    pub impairment_loss_total: Decimal,
191
192    /// Portion of `impairment_loss_total` allocated to goodwill
193    /// per IAS 36 § 104 — `min(impairment_loss_total, allocated_goodwill)`.
194    /// Goodwill impairments are NOT reversible (IAS 36 § 124).
195    #[serde(with = "crate::serde_decimal")]
196    pub impairment_loss_to_goodwill: Decimal,
197
198    /// Portion of `impairment_loss_total` allocated pro rata to the
199    /// CGU's other assets =
200    /// `impairment_loss_total − impairment_loss_to_goodwill`.
201    #[serde(with = "crate::serde_decimal")]
202    pub impairment_loss_to_other_assets: Decimal,
203
204    /// Group presentation currency.
205    pub currency: String,
206}
207
208impl CguImpairmentTest {
209    /// Run the IAS 36 § 18 / § 104 test, producing a
210    /// [`CguImpairmentResult`].  Pure function — no I/O, no global
211    /// state.
212    ///
213    /// 1. **Carrying total** = `allocated_goodwill + other_carrying`.
214    /// 2. **Recoverable amount** = `max(fair_value_less_costs, value_in_use)`.
215    /// 3. **Total loss** = `max(0, carrying − recoverable)`.
216    /// 4. **Allocation** (IAS 36 § 104): first to goodwill (capped at
217    ///    `allocated_goodwill`), then the residual to other assets.
218    pub fn run(&self) -> CguImpairmentResult {
219        let carrying_total = self.allocated_goodwill + self.other_carrying;
220        let recoverable_amount = self.fair_value_less_costs.max(self.value_in_use);
221        let impairment_loss_total = (carrying_total - recoverable_amount).max(Decimal::ZERO);
222        let impairment_loss_to_goodwill = impairment_loss_total.min(self.allocated_goodwill);
223        let impairment_loss_to_other_assets = impairment_loss_total - impairment_loss_to_goodwill;
224
225        CguImpairmentResult {
226            cgu_id: self.cgu_id.clone(),
227            test_date: self.test_date,
228            carrying_total,
229            recoverable_amount,
230            impairment_loss_total,
231            impairment_loss_to_goodwill,
232            impairment_loss_to_other_assets,
233            currency: self.currency.clone(),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use rust_decimal_macros::dec;
242
243    fn date() -> NaiveDate {
244        NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
245    }
246
247    fn impaired_test_sample() -> CguImpairmentTest {
248        // CGU carrying = 100k goodwill + 900k other = 1.0M.
249        // Fair value less costs = 750k; VIU = 800k → recoverable = 800k.
250        // Impairment = 1.0M − 800k = 200k.
251        // Allocation: 100k to goodwill (caps it out), 100k pro rata to other.
252        CguImpairmentTest {
253            cgu_id: "CGU-EMEA".to_string(),
254            test_date: date(),
255            allocated_goodwill: dec!(100_000),
256            other_carrying: dec!(900_000),
257            fair_value_less_costs: dec!(750_000),
258            value_in_use: dec!(800_000),
259            currency: "EUR".to_string(),
260        }
261    }
262
263    #[test]
264    fn impaired_cgu_allocates_to_goodwill_first() {
265        let result = impaired_test_sample().run();
266        assert_eq!(result.carrying_total, dec!(1_000_000));
267        assert_eq!(result.recoverable_amount, dec!(800_000));
268        assert_eq!(result.impairment_loss_total, dec!(200_000));
269        assert_eq!(
270            result.impairment_loss_to_goodwill,
271            dec!(100_000),
272            "IAS 36 § 104 — goodwill takes the first hit, capped at allocated amount"
273        );
274        assert_eq!(
275            result.impairment_loss_to_other_assets,
276            dec!(100_000),
277            "residual flows pro-rata to other assets"
278        );
279    }
280
281    #[test]
282    fn recoverable_cgu_has_no_impairment() {
283        // VIU well above carrying → no impairment.
284        let mut test = impaired_test_sample();
285        test.value_in_use = dec!(1_500_000);
286        let result = test.run();
287        assert_eq!(result.recoverable_amount, dec!(1_500_000));
288        assert_eq!(result.impairment_loss_total, Decimal::ZERO);
289        assert_eq!(result.impairment_loss_to_goodwill, Decimal::ZERO);
290        assert_eq!(result.impairment_loss_to_other_assets, Decimal::ZERO);
291    }
292
293    #[test]
294    fn impairment_smaller_than_goodwill_only_hits_goodwill() {
295        // Carrying = 1.0M, recoverable = 950k → 50k loss.
296        // 50k < 100k goodwill, so 100% to goodwill, 0 to other.
297        let mut test = impaired_test_sample();
298        test.fair_value_less_costs = dec!(950_000);
299        test.value_in_use = dec!(900_000);
300        let result = test.run();
301        assert_eq!(result.impairment_loss_total, dec!(50_000));
302        assert_eq!(result.impairment_loss_to_goodwill, dec!(50_000));
303        assert_eq!(result.impairment_loss_to_other_assets, Decimal::ZERO);
304    }
305
306    #[test]
307    fn impairment_uses_higher_of_fv_and_viu() {
308        // FV = 700k > VIU = 600k → recoverable = 700k.
309        let mut test = impaired_test_sample();
310        test.fair_value_less_costs = dec!(700_000);
311        test.value_in_use = dec!(600_000);
312        let result = test.run();
313        assert_eq!(
314            result.recoverable_amount,
315            dec!(700_000),
316            "recoverable = max(FV, VIU) per IAS 36 § 18"
317        );
318    }
319
320    #[test]
321    fn cgu_with_no_allocated_goodwill_only_impairs_other_assets() {
322        // Pure CGU impairment test (no goodwill component).  Allowed
323        // even though IAS 36 § 10 only mandates annual testing for
324        // CGUs containing goodwill — entities can still test other
325        // CGUs on indication.
326        let test = CguImpairmentTest {
327            cgu_id: "CGU-RND".to_string(),
328            test_date: date(),
329            allocated_goodwill: Decimal::ZERO,
330            other_carrying: dec!(500_000),
331            fair_value_less_costs: dec!(420_000),
332            value_in_use: dec!(400_000),
333            currency: "EUR".to_string(),
334        };
335        let result = test.run();
336        assert_eq!(result.impairment_loss_total, dec!(80_000));
337        assert_eq!(result.impairment_loss_to_goodwill, Decimal::ZERO);
338        assert_eq!(result.impairment_loss_to_other_assets, dec!(80_000));
339    }
340
341    #[test]
342    fn cgu_test_round_trips_via_serde() {
343        let test = impaired_test_sample();
344        let json = serde_json::to_string(&test).unwrap();
345        let back: CguImpairmentTest = serde_json::from_str(&json).unwrap();
346        assert_eq!(back, test);
347
348        let result = test.run();
349        let result_json = serde_json::to_string(&result).unwrap();
350        let result_back: CguImpairmentResult = serde_json::from_str(&result_json).unwrap();
351        assert_eq!(result_back, result);
352    }
353
354    #[test]
355    fn cgu_with_segment_carries_segment_code() {
356        let cgu = CashGeneratingUnit::new(
357            "CGU-EMEA",
358            "EMEA Consumer",
359            vec!["ACME_DE".to_string(), "ACME_FR".to_string()],
360        )
361        .with_segment("SEG-CONSUMER");
362        assert_eq!(cgu.segment_code.as_deref(), Some("SEG-CONSUMER"));
363        assert_eq!(cgu.member_entity_codes.len(), 2);
364    }
365
366    #[test]
367    fn goodwill_allocation_round_trips() {
368        let alloc = GoodwillAllocation {
369            cgu_id: "CGU-EMEA".to_string(),
370            business_combination_id: "BC-001".to_string(),
371            goodwill_amount: dec!(750_000),
372            allocation_date: date(),
373        };
374        let json = serde_json::to_string(&alloc).unwrap();
375        let back: GoodwillAllocation = serde_json::from_str(&json).unwrap();
376        assert_eq!(back, alloc);
377    }
378}