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)]
339mod tests {
340 use super::*;
341 use rust_decimal_macros::dec;
342
343 #[test]
344 fn test_group_consolidation_method_from_ownership() {
345 assert_eq!(
346 GroupConsolidationMethod::from_ownership(dec!(100)),
347 GroupConsolidationMethod::FullConsolidation
348 );
349 assert_eq!(
350 GroupConsolidationMethod::from_ownership(dec!(51)),
351 GroupConsolidationMethod::FullConsolidation
352 );
353 assert_eq!(
354 GroupConsolidationMethod::from_ownership(dec!(50)),
355 GroupConsolidationMethod::EquityMethod
356 );
357 assert_eq!(
358 GroupConsolidationMethod::from_ownership(dec!(20)),
359 GroupConsolidationMethod::EquityMethod
360 );
361 assert_eq!(
362 GroupConsolidationMethod::from_ownership(dec!(19)),
363 GroupConsolidationMethod::FairValue
364 );
365 assert_eq!(
366 GroupConsolidationMethod::from_ownership(dec!(0)),
367 GroupConsolidationMethod::FairValue
368 );
369 }
370
371 #[test]
372 fn nci_compute_legacy_path_uses_proportionate() {
373 // The v5.0 `compute()` shortcut must continue to behave as
374 // before (proportionate share of net assets). Net assets =
375 // 1_000_000, nci_percentage = 25 → 25% × 1M = 250k.
376 let m =
377 NciMeasurement::compute("SUB1".to_string(), dec!(25), dec!(1_000_000), dec!(120_000));
378 assert_eq!(m.method, NciMeasurementMethod::Proportionate);
379 assert_eq!(m.nci_share_net_assets, dec!(250_000));
380 assert_eq!(m.nci_share_profit, dec!(30_000));
381 assert_eq!(m.total_nci, dec!(250_000));
382 assert!(m.acquisition_date_fair_value.is_none());
383 }
384
385 #[test]
386 fn nci_compute_full_goodwill_uses_acquisition_date_fair_value() {
387 // IFRS 3.19(a): NCI is measured at its acquisition-date fair
388 // value. Total NCI = the supplied fair value (310k); the
389 // proportionate net-asset figure (250k) becomes the bookkeeping
390 // floor for share-of-net-assets disclosures but does NOT drive
391 // total_nci. The "extra 60k" represents the NCI's share of
392 // goodwill recognised on consolidation.
393 let m = NciMeasurement::compute_with_method(
394 "SUB1".to_string(),
395 dec!(25),
396 dec!(1_000_000),
397 dec!(120_000),
398 NciMeasurementMethod::FullGoodwill,
399 Some(dec!(310_000)),
400 );
401 assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
402 assert_eq!(m.acquisition_date_fair_value, Some(dec!(310_000)));
403 assert_eq!(m.nci_share_net_assets, dec!(250_000));
404 assert_eq!(m.total_nci, dec!(310_000), "full-goodwill total NCI = FV");
405 }
406
407 #[test]
408 fn nci_compute_full_goodwill_without_fair_value_falls_back() {
409 // Caller asks for FullGoodwill but doesn't supply a fair
410 // value. Must NOT panic — fall back to the proportionate
411 // calculation and emit a warning. Total = nci_share_net_assets.
412 let m = NciMeasurement::compute_with_method(
413 "SUB1".to_string(),
414 dec!(40),
415 dec!(500_000),
416 dec!(50_000),
417 NciMeasurementMethod::FullGoodwill,
418 None,
419 );
420 assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
421 assert!(m.acquisition_date_fair_value.is_none());
422 assert_eq!(
423 m.total_nci,
424 dec!(200_000),
425 "missing FV must fall back to proportionate (40% of 500k)"
426 );
427 }
428
429 #[test]
430 fn nci_compute_proportionate_with_disclosure_fair_value() {
431 // Proportionate method but caller wants to record a FV for
432 // disclosure (IFRS 3.19 lets the entity choose; some entities
433 // disclose the FV they would have used had they elected the
434 // alternative method). total_nci stays proportionate; the FV
435 // is preserved for serialisation.
436 let m = NciMeasurement::compute_with_method(
437 "SUB1".to_string(),
438 dec!(30),
439 dec!(800_000),
440 dec!(100_000),
441 NciMeasurementMethod::Proportionate,
442 Some(dec!(280_000)),
443 );
444 assert_eq!(m.total_nci, dec!(240_000), "proportionate: 30% of 800k");
445 assert_eq!(m.acquisition_date_fair_value, Some(dec!(280_000)));
446 }
447}