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