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)]
239#[allow(clippy::unwrap_used)]
240mod tests {
241 use super::*;
242 use rust_decimal_macros::dec;
243
244 fn date() -> NaiveDate {
245 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
246 }
247
248 fn impaired_test_sample() -> CguImpairmentTest {
249 // CGU carrying = 100k goodwill + 900k other = 1.0M.
250 // Fair value less costs = 750k; VIU = 800k → recoverable = 800k.
251 // Impairment = 1.0M − 800k = 200k.
252 // Allocation: 100k to goodwill (caps it out), 100k pro rata to other.
253 CguImpairmentTest {
254 cgu_id: "CGU-EMEA".to_string(),
255 test_date: date(),
256 allocated_goodwill: dec!(100_000),
257 other_carrying: dec!(900_000),
258 fair_value_less_costs: dec!(750_000),
259 value_in_use: dec!(800_000),
260 currency: "EUR".to_string(),
261 }
262 }
263
264 #[test]
265 fn impaired_cgu_allocates_to_goodwill_first() {
266 let result = impaired_test_sample().run();
267 assert_eq!(result.carrying_total, dec!(1_000_000));
268 assert_eq!(result.recoverable_amount, dec!(800_000));
269 assert_eq!(result.impairment_loss_total, dec!(200_000));
270 assert_eq!(
271 result.impairment_loss_to_goodwill,
272 dec!(100_000),
273 "IAS 36 § 104 — goodwill takes the first hit, capped at allocated amount"
274 );
275 assert_eq!(
276 result.impairment_loss_to_other_assets,
277 dec!(100_000),
278 "residual flows pro-rata to other assets"
279 );
280 }
281
282 #[test]
283 fn recoverable_cgu_has_no_impairment() {
284 // VIU well above carrying → no impairment.
285 let mut test = impaired_test_sample();
286 test.value_in_use = dec!(1_500_000);
287 let result = test.run();
288 assert_eq!(result.recoverable_amount, dec!(1_500_000));
289 assert_eq!(result.impairment_loss_total, Decimal::ZERO);
290 assert_eq!(result.impairment_loss_to_goodwill, Decimal::ZERO);
291 assert_eq!(result.impairment_loss_to_other_assets, Decimal::ZERO);
292 }
293
294 #[test]
295 fn impairment_smaller_than_goodwill_only_hits_goodwill() {
296 // Carrying = 1.0M, recoverable = 950k → 50k loss.
297 // 50k < 100k goodwill, so 100% to goodwill, 0 to other.
298 let mut test = impaired_test_sample();
299 test.fair_value_less_costs = dec!(950_000);
300 test.value_in_use = dec!(900_000);
301 let result = test.run();
302 assert_eq!(result.impairment_loss_total, dec!(50_000));
303 assert_eq!(result.impairment_loss_to_goodwill, dec!(50_000));
304 assert_eq!(result.impairment_loss_to_other_assets, Decimal::ZERO);
305 }
306
307 #[test]
308 fn impairment_uses_higher_of_fv_and_viu() {
309 // FV = 700k > VIU = 600k → recoverable = 700k.
310 let mut test = impaired_test_sample();
311 test.fair_value_less_costs = dec!(700_000);
312 test.value_in_use = dec!(600_000);
313 let result = test.run();
314 assert_eq!(
315 result.recoverable_amount,
316 dec!(700_000),
317 "recoverable = max(FV, VIU) per IAS 36 § 18"
318 );
319 }
320
321 #[test]
322 fn cgu_with_no_allocated_goodwill_only_impairs_other_assets() {
323 // Pure CGU impairment test (no goodwill component). Allowed
324 // even though IAS 36 § 10 only mandates annual testing for
325 // CGUs containing goodwill — entities can still test other
326 // CGUs on indication.
327 let test = CguImpairmentTest {
328 cgu_id: "CGU-RND".to_string(),
329 test_date: date(),
330 allocated_goodwill: Decimal::ZERO,
331 other_carrying: dec!(500_000),
332 fair_value_less_costs: dec!(420_000),
333 value_in_use: dec!(400_000),
334 currency: "EUR".to_string(),
335 };
336 let result = test.run();
337 assert_eq!(result.impairment_loss_total, dec!(80_000));
338 assert_eq!(result.impairment_loss_to_goodwill, Decimal::ZERO);
339 assert_eq!(result.impairment_loss_to_other_assets, dec!(80_000));
340 }
341
342 #[test]
343 fn cgu_test_round_trips_via_serde() {
344 let test = impaired_test_sample();
345 let json = serde_json::to_string(&test).unwrap();
346 let back: CguImpairmentTest = serde_json::from_str(&json).unwrap();
347 assert_eq!(back, test);
348
349 let result = test.run();
350 let result_json = serde_json::to_string(&result).unwrap();
351 let result_back: CguImpairmentResult = serde_json::from_str(&result_json).unwrap();
352 assert_eq!(result_back, result);
353 }
354
355 #[test]
356 fn cgu_with_segment_carries_segment_code() {
357 let cgu = CashGeneratingUnit::new(
358 "CGU-EMEA",
359 "EMEA Consumer",
360 vec!["NESTLE_DE".to_string(), "NESTLE_FR".to_string()],
361 )
362 .with_segment("SEG-CONSUMER");
363 assert_eq!(cgu.segment_code.as_deref(), Some("SEG-CONSUMER"));
364 assert_eq!(cgu.member_entity_codes.len(), 2);
365 }
366
367 #[test]
368 fn goodwill_allocation_round_trips() {
369 let alloc = GoodwillAllocation {
370 cgu_id: "CGU-EMEA".to_string(),
371 business_combination_id: "BC-001".to_string(),
372 goodwill_amount: dec!(750_000),
373 allocation_date: date(),
374 };
375 let json = serde_json::to_string(&alloc).unwrap();
376 let back: GoodwillAllocation = serde_json::from_str(&json).unwrap();
377 assert_eq!(back, alloc);
378 }
379}