Skip to main content

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}