datasynth_group/aggregate/equity_method.rs
1//! Equity-method investment rollforward — Task 7.3.
2//!
3//! After the IC-pair matcher (Task 5.3) has joined every fully-
4//! consolidated subsidiary's ledgers, the aggregate phase still has to
5//! account for joint ventures and significant-influence associates that
6//! were *not* line-by-line consolidated. These investees were held back
7//! by [`crate::aggregate::pre_elim::aggregate_pre_elimination`] in
8//! [`crate::aggregate::pre_elim::DeferredEntity`] sidecars; this module
9//! processes them via the IAS 28 / ASC 323 equity-method single-line
10//! treatment.
11//!
12//! # Standards reference
13//!
14//! - **IAS 28** *Investments in Associates and Joint Ventures* §§ 10–11:
15//! the investor recognises its share of the investee's profit or loss
16//! in its own profit or loss. Distributions received from the
17//! investee reduce the carrying amount of the investment.
18//! - **IAS 28 § 16** — the carrying amount of the investment is
19//! reduced when impairment is recognised (IAS 36 reference). In v5.0
20//! the caller supplies the impairment amount; auto-impairment is
21//! deferred to a later chunk.
22//! - **IAS 28 § 38** — when the investor's share of losses equals or
23//! exceeds its interest in the investee, the investor *discontinues*
24//! recognising its share of further losses; the carrying amount must
25//! not go below zero (with limited exceptions for guaranteed
26//! obligations).
27//! - **US GAAP — ASC 323** *Investments — Equity Method and Joint
28//! Ventures*: the same rollforward identity applies.
29//!
30//! # v5.1 scope (extended from v5.0)
31//!
32//! - **EquityMethod consolidation only.** Reject `Parent` / `Full` /
33//! `Proportional` / `FairValue` (those are handled elsewhere).
34//! - **Ownership in `(0, 1)`.** Boundary values (zero or full) are not
35//! meaningful for equity-method treatment and likely indicate a
36//! caller bug.
37//! - **IAS 28 § 38 first paragraph (clamp).** When the rollforward
38//! would push the carrying value below zero, the carrying value is
39//! clamped at zero and the unrecognised amount accumulates in the
40//! suppressed-loss memorandum (`suppressed_loss_this_period` +
41//! `closing_suppressed_loss`). v5.0 logged the suppressed amount
42//! but did not surface it to consumers; v5.1 emits the per-investee
43//! memorandum on every record plus a filtered `*_suppressed_losses`
44//! side-artefact.
45//! - **IAS 28 § 38 second paragraph (recovery against future profits).**
46//! When the investee subsequently reports profits, the entity resumes
47//! recognising its share only after its share of profits equals the
48//! share of losses not recognised. The caller passes
49//! `opening_suppressed_loss` (typically read from the prior period
50//! via [`ingest_opening_suppressed_losses`]); positive
51//! `share_of_profit` is applied against this opening balance first,
52//! and only the residual flows to `share_of_profit_recognised` and
53//! the carrying-value rollforward.
54//!
55//! # File-not-found semantics
56//!
57//! Mirrors [`super::nci::opening::ingest_opening_nci_balances`]:
58//! missing prior-period file ≡ first-period engagement, log a warning
59//! and return an empty map.
60
61use std::collections::BTreeMap;
62use std::fs;
63use std::path::{Path, PathBuf};
64
65use chrono::NaiveDate;
66use rust_decimal::Decimal;
67use serde::{Deserialize, Serialize};
68
69use crate::config::ConsolidationMethod;
70use crate::errors::{GroupError, GroupResult};
71use crate::manifest::ManifestEntity;
72
73/// Subdirectory within the group output root for the consolidated
74/// equity-method rollforward, mirroring the
75/// [`crate::aggregate::nci::opening::CONSOLIDATED_SUBDIR`] layout.
76pub const CONSOLIDATED_SUBDIR: &str = "consolidated";
77
78/// File name for the on-disk equity-method investment rollforward
79/// array.
80pub const EQUITY_METHOD_INVESTMENTS_FILENAME: &str = "equity_method_investments.json";
81
82/// File name for the v5.1+ IAS 28.38 suppressed-loss memorandum
83/// artefact. Contains only the records where
84/// `closing_suppressed_loss > 0`, surfaced separately so consumers
85/// can disclose suppressed-loss balances without scanning the full
86/// equity-method file.
87pub const EQUITY_METHOD_SUPPRESSED_LOSSES_FILENAME: &str = "equity_method_suppressed_losses.json";
88
89// ── Public types ──────────────────────────────────────────────────────────────
90
91/// One equity-method investment's rollforward record for the period.
92///
93/// `closing_carrying_value = opening + share_of_profit_recognised
94/// - dividends_received - impairment`
95/// per IAS 28.10–11 / ASC 323-10-35, **clamped at zero** per IAS 28.38.
96///
97/// When the rollforward would push the carrying value below zero, the
98/// investor discontinues recognising further losses; the unrecognised
99/// amount is tracked as `suppressed_loss_this_period` and accumulated
100/// in `closing_suppressed_loss` for use against future profits.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct EquityMethodInvestment {
103 /// Code of the investee (associate or joint venture).
104 pub investee_code: String,
105 /// Code of the investor entity (the parent who holds the
106 /// investment). Carried explicitly so the consolidated note
107 /// disclosure can attribute the investment without re-walking the
108 /// manifest.
109 pub investor_entity_code: String,
110 /// Investor's ownership share of the investee, in `(0, 1)`.
111 pub ownership_percent: Decimal,
112 /// Carrying amount of the investment brought forward from the
113 /// prior period (zero on the first period of an engagement).
114 pub opening_carrying_value: Decimal,
115 /// Cumulative losses suppressed (not recognised in profit or loss)
116 /// brought forward from prior periods per IAS 28.38. Zero on the
117 /// first period of an engagement. v5.1+: applied against current
118 /// period profits before any further share is recognised.
119 #[serde(default)]
120 pub opening_suppressed_loss: Decimal,
121 /// Investor's "natural" share of the investee's period net
122 /// income = `ownership_percent * investee_net_income` (IAS 28.10),
123 /// before any clawback against opening suppressed losses.
124 pub share_of_profit: Decimal,
125 /// Amount of `share_of_profit` actually recognised in profit or
126 /// loss this period. Equals `share_of_profit` when there is no
127 /// opening suppressed loss; otherwise reduced by the portion
128 /// applied against the opening cumulative suppressed loss
129 /// (IAS 28.38 second paragraph).
130 #[serde(default)]
131 pub share_of_profit_recognised: Decimal,
132 /// Distributions (dividends) received from the investee =
133 /// `ownership_percent * investee_dividends_paid`. Reduces the
134 /// carrying amount.
135 pub dividends_received: Decimal,
136 /// Impairment loss recognised this period (IAS 28.40 / IAS 36).
137 /// Caller supplies the amount; v5.0 has no auto-impairment. Always
138 /// non-negative.
139 pub impairment: Decimal,
140 /// Loss not recognised this period because the rollforward would
141 /// have pushed the carrying value below zero (IAS 28.38). Always
142 /// non-negative. Zero when the carrying value rollforward stays
143 /// at or above zero.
144 #[serde(default)]
145 pub suppressed_loss_this_period: Decimal,
146 /// Cumulative suppressed losses carried forward to next period =
147 /// `(opening_suppressed_loss − recovered) + suppressed_loss_this_period`,
148 /// where `recovered` is the portion of opening suppressed losses
149 /// applied against current-period share of profit. Used as next
150 /// period's `opening_suppressed_loss`.
151 #[serde(default)]
152 pub closing_suppressed_loss: Decimal,
153 /// Closing carrying value =
154 /// `opening + share_of_profit_recognised - dividends_received - impairment`,
155 /// rounded to 2dp and clamped at zero (IAS 28.38).
156 pub closing_carrying_value: Decimal,
157 /// Period end date the rollforward is as of.
158 pub period_end: NaiveDate,
159 /// Group presentation currency.
160 pub currency: String,
161}
162
163/// Inputs required to derive an [`EquityMethodInvestment`].
164///
165/// The caller is responsible for already having translated
166/// `investee_net_income` and `investee_dividends_paid` into the group
167/// presentation currency (Chunk 6).
168pub struct EquityMethodInputs<'a> {
169 /// Reference to the investee's manifest entity. Provides the code,
170 /// ownership percent, and consolidation method used to validate
171 /// inputs.
172 pub investee: &'a ManifestEntity,
173 /// Code of the investor entity (parent who holds the investment).
174 pub investor_entity_code: String,
175 /// Investee's period net income (after tax).
176 pub investee_net_income: Decimal,
177 /// Investee's total dividends paid this period (gross — both
178 /// to controlling and non-controlling shareholders; the share
179 /// the investor receives is `ownership * total`).
180 pub investee_dividends_paid: Decimal,
181 /// Carrying amount brought forward from the prior period.
182 pub opening_carrying_value: Decimal,
183 /// Cumulative losses brought forward from prior periods that the
184 /// investor previously suppressed under IAS 28.38. Zero on the
185 /// first period of an engagement. Applied against the current
186 /// period's `share_of_profit` before any is recognised.
187 pub opening_suppressed_loss: Decimal,
188 /// Impairment loss to recognise this period. Always non-negative;
189 /// zero by default if no impairment indicator is observed.
190 pub impairment: Decimal,
191 /// Period end date.
192 pub period_end: NaiveDate,
193 /// Group presentation currency.
194 pub currency: String,
195}
196
197// ── Public API ────────────────────────────────────────────────────────────────
198
199/// Derive an [`EquityMethodInvestment`] for one investee.
200///
201/// Pure function: no I/O, no allocation beyond the record itself.
202///
203/// # Validation
204///
205/// 1. `investee.consolidation_method` **must** be
206/// [`ConsolidationMethod::EquityMethod`].
207/// 2. `investee.ownership_percent` **must** be present and in `(0, 1)`
208/// (strict inequalities — boundary values aren't meaningful for
209/// equity-method treatment).
210/// 3. The closing carrying value **must** remain non-negative. IAS
211/// 28.38 / ASC 323-10-35-20 require the investor to discontinue
212/// recognising further losses once the carrying amount hits zero;
213/// we surface this as a typed error so the caller can decide whether
214/// to clamp the share of loss or recognise an additional liability.
215pub fn compute_equity_method_investment(
216 inputs: &EquityMethodInputs,
217) -> GroupResult<EquityMethodInvestment> {
218 let investee = inputs.investee;
219
220 // 1. Reject any non-EquityMethod consolidation method.
221 if investee.consolidation_method != ConsolidationMethod::EquityMethod {
222 return Err(GroupError::Aggregate(format!(
223 "compute_equity_method_investment: entity `{}` has \
224 consolidation_method={:?} — equity-method treatment is only \
225 valid for ConsolidationMethod::EquityMethod (Parent / Full are \
226 line-by-line consolidated; Proportional / FairValue use other \
227 methods)",
228 investee.code, investee.consolidation_method,
229 )));
230 }
231
232 // 2. Ownership must be strictly in (0, 1).
233 let ownership_percent = investee.ownership_percent.ok_or_else(|| {
234 GroupError::Aggregate(format!(
235 "compute_equity_method_investment: entity `{}` is \
236 consolidation_method=EquityMethod but has no ownership_percent \
237 set — supply ownership_percent in (0, 1)",
238 investee.code,
239 ))
240 })?;
241 if ownership_percent <= Decimal::ZERO || ownership_percent >= Decimal::ONE {
242 return Err(GroupError::Aggregate(format!(
243 "compute_equity_method_investment: entity `{}` ownership_percent={} \
244 is outside (0, 1) — equity-method treatment requires strict \
245 0 < ownership < 1",
246 investee.code, ownership_percent,
247 )));
248 }
249
250 // 3. Apply the IAS 28.10–11 rollforward identity.
251 let share_of_profit = ownership_percent * inputs.investee_net_income;
252 let dividends_received = ownership_percent * inputs.investee_dividends_paid;
253
254 // IAS 28.38 second paragraph: if the investee subsequently reports
255 // profits, the entity resumes recognising its share only after
256 // its share of profits equals the share of losses not recognised.
257 // Apply opening suppressed losses against any positive share of
258 // profit BEFORE the rollforward, so the carrying value only
259 // increases for the residual.
260 let opening_suppressed = inputs.opening_suppressed_loss.max(Decimal::ZERO);
261 let (share_of_profit_recognised, suppressed_after_recovery) =
262 if share_of_profit > Decimal::ZERO && opening_suppressed > Decimal::ZERO {
263 let recovered = share_of_profit.min(opening_suppressed);
264 (share_of_profit - recovered, opening_suppressed - recovered)
265 } else {
266 (share_of_profit, opening_suppressed)
267 };
268
269 let raw_closing = (inputs.opening_carrying_value + share_of_profit_recognised
270 - dividends_received
271 - inputs.impairment)
272 .round_dp(2);
273
274 // IAS 28.38 / ASC 323-10-35-20: when the rollforward would push
275 // the carrying amount below zero, discontinue recognising further
276 // losses. The investment is reported at zero and the unrecognised
277 // amount accumulates in the suppressed-loss memorandum. v5.1+:
278 // the gap is tracked explicitly via `suppressed_loss_this_period`
279 // + `closing_suppressed_loss`, restoring the IAS 28.38 second
280 // paragraph (recovery against future profits).
281 let (closing_carrying_value, suppressed_loss_this_period) = if raw_closing < Decimal::ZERO {
282 tracing::debug!(
283 investee = %investee.code,
284 raw_closing = %raw_closing,
285 opening = %inputs.opening_carrying_value,
286 share_of_profit_recognised = %share_of_profit_recognised,
287 dividends_received = %dividends_received,
288 impairment = %inputs.impairment,
289 "equity-method carrying value clamped at zero per IAS 28.38; suppressed loss tracked",
290 );
291 (Decimal::ZERO, (-raw_closing).round_dp(2))
292 } else {
293 (raw_closing, Decimal::ZERO)
294 };
295
296 let closing_suppressed_loss =
297 (suppressed_after_recovery + suppressed_loss_this_period).round_dp(2);
298
299 Ok(EquityMethodInvestment {
300 investee_code: investee.code.clone(),
301 investor_entity_code: inputs.investor_entity_code.clone(),
302 ownership_percent,
303 opening_carrying_value: inputs.opening_carrying_value.round_dp(2),
304 opening_suppressed_loss: opening_suppressed.round_dp(2),
305 share_of_profit: share_of_profit.round_dp(2),
306 share_of_profit_recognised: share_of_profit_recognised.round_dp(2),
307 dividends_received: dividends_received.round_dp(2),
308 impairment: inputs.impairment.round_dp(2),
309 suppressed_loss_this_period,
310 closing_suppressed_loss,
311 closing_carrying_value,
312 period_end: inputs.period_end,
313 currency: inputs.currency.clone(),
314 })
315}
316
317/// Write an array of [`EquityMethodInvestment`] records to
318/// `{out_dir}/consolidated/equity_method_investments.json`.
319///
320/// Creates the `consolidated/` subdirectory if it doesn't already
321/// exist. Output is pretty-printed JSON with a trailing newline.
322/// Returns the absolute path of the written file.
323///
324/// # Errors
325///
326/// - [`GroupError::Io`] on subdirectory creation or file write failure.
327/// - [`GroupError::Serde`] if serialisation fails (should be
328/// impossible — every field is `Serialize`-friendly).
329pub fn write_equity_method_investments(
330 investments: &[EquityMethodInvestment],
331 out_dir: &Path,
332) -> GroupResult<PathBuf> {
333 let dir = out_dir.join(CONSOLIDATED_SUBDIR);
334 fs::create_dir_all(&dir).map_err(GroupError::Io)?;
335
336 let path = dir.join(EQUITY_METHOD_INVESTMENTS_FILENAME);
337
338 let mut json = serde_json::to_string_pretty(investments)?;
339 json.push('\n');
340 fs::write(&path, json).map_err(GroupError::Io)?;
341
342 Ok(path)
343}
344
345/// Read prior-period closing carrying values as this period's opening,
346/// mirror of
347/// [`crate::aggregate::nci::opening::ingest_opening_nci_balances`].
348///
349/// Walks `{prior_period_dir}/consolidated/equity_method_investments.json`
350/// and returns a map of `(investee_code -> closing_carrying_value)` from
351/// the prior period.
352///
353/// # Errors
354///
355/// - [`GroupError::Serde`] if the file exists but cannot be parsed.
356/// - [`GroupError::Aggregate`] if the file contains two or more records
357/// for the same `investee_code`.
358/// - Missing file → `Ok(BTreeMap::new())` plus a `tracing::warn!` log.
359pub fn ingest_opening_equity_method_carrying_values(
360 prior_period_dir: &Path,
361) -> GroupResult<BTreeMap<String, Decimal>> {
362 let path = prior_period_dir
363 .join(CONSOLIDATED_SUBDIR)
364 .join(EQUITY_METHOD_INVESTMENTS_FILENAME);
365
366 if !path.exists() {
367 tracing::warn!(
368 path = %path.display(),
369 "opening equity-method investments file not found; defaulting \
370 to zero opening carrying value per investee"
371 );
372 return Ok(BTreeMap::new());
373 }
374
375 let bytes = fs::read(&path).map_err(GroupError::Io)?;
376 let investments: Vec<EquityMethodInvestment> = serde_json::from_slice(&bytes)?;
377
378 let mut map: BTreeMap<String, Decimal> = BTreeMap::new();
379 for inv in investments {
380 if map.contains_key(&inv.investee_code) {
381 return Err(GroupError::Aggregate(format!(
382 "ingest_opening_equity_method_carrying_values: duplicate \
383 investee `{}` in opening file {} — writer regression?",
384 inv.investee_code,
385 path.display(),
386 )));
387 }
388 map.insert(inv.investee_code, inv.closing_carrying_value);
389 }
390
391 Ok(map)
392}
393
394/// Read prior-period closing suppressed-loss balances as this period's
395/// opening suppressed losses (IAS 28.38 second paragraph). Returns
396/// `(investee_code -> closing_suppressed_loss)` for every investee in
397/// the prior-period file, even when the value is zero (callers can
398/// `unwrap_or(Decimal::ZERO)` if a code is absent).
399///
400/// Mirrors the I/O contract of
401/// [`ingest_opening_equity_method_carrying_values`] — same file, just
402/// reading a different field.
403///
404/// # Errors
405///
406/// - [`GroupError::Serde`] if the file exists but cannot be parsed.
407/// - [`GroupError::Aggregate`] if the file contains two or more records
408/// for the same `investee_code`.
409/// - Missing file → `Ok(BTreeMap::new())` plus a `tracing::warn!` log.
410pub fn ingest_opening_suppressed_losses(
411 prior_period_dir: &Path,
412) -> GroupResult<BTreeMap<String, Decimal>> {
413 let path = prior_period_dir
414 .join(CONSOLIDATED_SUBDIR)
415 .join(EQUITY_METHOD_INVESTMENTS_FILENAME);
416
417 if !path.exists() {
418 // Mirror the ingest_opening_carrying_values behaviour: warn
419 // once on first-period engagements and return empty. No
420 // separate warn here — the carrying-value ingest already
421 // logs.
422 return Ok(BTreeMap::new());
423 }
424
425 let bytes = fs::read(&path).map_err(GroupError::Io)?;
426 let investments: Vec<EquityMethodInvestment> = serde_json::from_slice(&bytes)?;
427
428 let mut map: BTreeMap<String, Decimal> = BTreeMap::new();
429 for inv in investments {
430 if map.contains_key(&inv.investee_code) {
431 return Err(GroupError::Aggregate(format!(
432 "ingest_opening_suppressed_losses: duplicate \
433 investee `{}` in opening file {} — writer regression?",
434 inv.investee_code,
435 path.display(),
436 )));
437 }
438 map.insert(inv.investee_code, inv.closing_suppressed_loss);
439 }
440
441 Ok(map)
442}
443
444/// Write the v5.1+ IAS 28.38 suppressed-loss memorandum artefact.
445///
446/// Filters the input to only the records with
447/// `closing_suppressed_loss > 0` so the artefact stays small and
448/// readers can map it directly to the disclosure note. Records
449/// without suppressed losses don't need a disclosure entry.
450///
451/// Output path:
452/// `{out_dir}/consolidated/equity_method_suppressed_losses.json`
453///
454/// # Errors
455///
456/// - [`GroupError::Io`] on subdirectory creation or file write failure.
457/// - [`GroupError::Serde`] if serialisation fails.
458pub fn write_suppressed_losses(
459 investments: &[EquityMethodInvestment],
460 out_dir: &Path,
461) -> GroupResult<PathBuf> {
462 let dir = out_dir.join(CONSOLIDATED_SUBDIR);
463 fs::create_dir_all(&dir).map_err(GroupError::Io)?;
464
465 let path = dir.join(EQUITY_METHOD_SUPPRESSED_LOSSES_FILENAME);
466
467 let filtered: Vec<&EquityMethodInvestment> = investments
468 .iter()
469 .filter(|i| i.closing_suppressed_loss > Decimal::ZERO)
470 .collect();
471
472 let mut json = serde_json::to_string_pretty(&filtered)?;
473 json.push('\n');
474 fs::write(&path, json).map_err(GroupError::Io)?;
475
476 Ok(path)
477}