use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::config::ConsolidationMethod;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::ManifestEntity;
pub const CONSOLIDATED_SUBDIR: &str = "consolidated";
pub const EQUITY_METHOD_INVESTMENTS_FILENAME: &str = "equity_method_investments.json";
pub const EQUITY_METHOD_SUPPRESSED_LOSSES_FILENAME: &str = "equity_method_suppressed_losses.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EquityMethodInvestment {
pub investee_code: String,
pub investor_entity_code: String,
pub ownership_percent: Decimal,
pub opening_carrying_value: Decimal,
#[serde(default)]
pub opening_suppressed_loss: Decimal,
pub share_of_profit: Decimal,
#[serde(default)]
pub share_of_profit_recognised: Decimal,
pub dividends_received: Decimal,
pub impairment: Decimal,
#[serde(default)]
pub suppressed_loss_this_period: Decimal,
#[serde(default)]
pub closing_suppressed_loss: Decimal,
pub closing_carrying_value: Decimal,
pub period_end: NaiveDate,
pub currency: String,
}
pub struct EquityMethodInputs<'a> {
pub investee: &'a ManifestEntity,
pub investor_entity_code: String,
pub investee_net_income: Decimal,
pub investee_dividends_paid: Decimal,
pub opening_carrying_value: Decimal,
pub opening_suppressed_loss: Decimal,
pub impairment: Decimal,
pub period_end: NaiveDate,
pub currency: String,
}
pub fn compute_equity_method_investment(
inputs: &EquityMethodInputs,
) -> GroupResult<EquityMethodInvestment> {
let investee = inputs.investee;
if investee.consolidation_method != ConsolidationMethod::EquityMethod {
return Err(GroupError::Aggregate(format!(
"compute_equity_method_investment: entity `{}` has \
consolidation_method={:?} — equity-method treatment is only \
valid for ConsolidationMethod::EquityMethod (Parent / Full are \
line-by-line consolidated; Proportional / FairValue use other \
methods)",
investee.code, investee.consolidation_method,
)));
}
let ownership_percent = investee.ownership_percent.ok_or_else(|| {
GroupError::Aggregate(format!(
"compute_equity_method_investment: entity `{}` is \
consolidation_method=EquityMethod but has no ownership_percent \
set — supply ownership_percent in (0, 1)",
investee.code,
))
})?;
if ownership_percent <= Decimal::ZERO || ownership_percent >= Decimal::ONE {
return Err(GroupError::Aggregate(format!(
"compute_equity_method_investment: entity `{}` ownership_percent={} \
is outside (0, 1) — equity-method treatment requires strict \
0 < ownership < 1",
investee.code, ownership_percent,
)));
}
let share_of_profit = ownership_percent * inputs.investee_net_income;
let dividends_received = ownership_percent * inputs.investee_dividends_paid;
let opening_suppressed = inputs.opening_suppressed_loss.max(Decimal::ZERO);
let (share_of_profit_recognised, suppressed_after_recovery) =
if share_of_profit > Decimal::ZERO && opening_suppressed > Decimal::ZERO {
let recovered = share_of_profit.min(opening_suppressed);
(share_of_profit - recovered, opening_suppressed - recovered)
} else {
(share_of_profit, opening_suppressed)
};
let raw_closing = (inputs.opening_carrying_value + share_of_profit_recognised
- dividends_received
- inputs.impairment)
.round_dp(2);
let (closing_carrying_value, suppressed_loss_this_period) = if raw_closing < Decimal::ZERO {
tracing::debug!(
investee = %investee.code,
raw_closing = %raw_closing,
opening = %inputs.opening_carrying_value,
share_of_profit_recognised = %share_of_profit_recognised,
dividends_received = %dividends_received,
impairment = %inputs.impairment,
"equity-method carrying value clamped at zero per IAS 28.38; suppressed loss tracked",
);
(Decimal::ZERO, (-raw_closing).round_dp(2))
} else {
(raw_closing, Decimal::ZERO)
};
let closing_suppressed_loss =
(suppressed_after_recovery + suppressed_loss_this_period).round_dp(2);
Ok(EquityMethodInvestment {
investee_code: investee.code.clone(),
investor_entity_code: inputs.investor_entity_code.clone(),
ownership_percent,
opening_carrying_value: inputs.opening_carrying_value.round_dp(2),
opening_suppressed_loss: opening_suppressed.round_dp(2),
share_of_profit: share_of_profit.round_dp(2),
share_of_profit_recognised: share_of_profit_recognised.round_dp(2),
dividends_received: dividends_received.round_dp(2),
impairment: inputs.impairment.round_dp(2),
suppressed_loss_this_period,
closing_suppressed_loss,
closing_carrying_value,
period_end: inputs.period_end,
currency: inputs.currency.clone(),
})
}
pub fn write_equity_method_investments(
investments: &[EquityMethodInvestment],
out_dir: &Path,
) -> GroupResult<PathBuf> {
let dir = out_dir.join(CONSOLIDATED_SUBDIR);
fs::create_dir_all(&dir).map_err(GroupError::Io)?;
let path = dir.join(EQUITY_METHOD_INVESTMENTS_FILENAME);
let mut json = serde_json::to_string_pretty(investments)?;
json.push('\n');
fs::write(&path, json).map_err(GroupError::Io)?;
Ok(path)
}
pub fn ingest_opening_equity_method_carrying_values(
prior_period_dir: &Path,
) -> GroupResult<BTreeMap<String, Decimal>> {
let path = prior_period_dir
.join(CONSOLIDATED_SUBDIR)
.join(EQUITY_METHOD_INVESTMENTS_FILENAME);
if !path.exists() {
tracing::warn!(
path = %path.display(),
"opening equity-method investments file not found; defaulting \
to zero opening carrying value per investee"
);
return Ok(BTreeMap::new());
}
let bytes = fs::read(&path).map_err(GroupError::Io)?;
let investments: Vec<EquityMethodInvestment> = serde_json::from_slice(&bytes)?;
let mut map: BTreeMap<String, Decimal> = BTreeMap::new();
for inv in investments {
if map.contains_key(&inv.investee_code) {
return Err(GroupError::Aggregate(format!(
"ingest_opening_equity_method_carrying_values: duplicate \
investee `{}` in opening file {} — writer regression?",
inv.investee_code,
path.display(),
)));
}
map.insert(inv.investee_code, inv.closing_carrying_value);
}
Ok(map)
}
pub fn ingest_opening_suppressed_losses(
prior_period_dir: &Path,
) -> GroupResult<BTreeMap<String, Decimal>> {
let path = prior_period_dir
.join(CONSOLIDATED_SUBDIR)
.join(EQUITY_METHOD_INVESTMENTS_FILENAME);
if !path.exists() {
return Ok(BTreeMap::new());
}
let bytes = fs::read(&path).map_err(GroupError::Io)?;
let investments: Vec<EquityMethodInvestment> = serde_json::from_slice(&bytes)?;
let mut map: BTreeMap<String, Decimal> = BTreeMap::new();
for inv in investments {
if map.contains_key(&inv.investee_code) {
return Err(GroupError::Aggregate(format!(
"ingest_opening_suppressed_losses: duplicate \
investee `{}` in opening file {} — writer regression?",
inv.investee_code,
path.display(),
)));
}
map.insert(inv.investee_code, inv.closing_suppressed_loss);
}
Ok(map)
}
pub fn write_suppressed_losses(
investments: &[EquityMethodInvestment],
out_dir: &Path,
) -> GroupResult<PathBuf> {
let dir = out_dir.join(CONSOLIDATED_SUBDIR);
fs::create_dir_all(&dir).map_err(GroupError::Io)?;
let path = dir.join(EQUITY_METHOD_SUPPRESSED_LOSSES_FILENAME);
let filtered: Vec<&EquityMethodInvestment> = investments
.iter()
.filter(|i| i.closing_suppressed_loss > Decimal::ZERO)
.collect();
let mut json = serde_json::to_string_pretty(&filtered)?;
json.push('\n');
fs::write(&path, json).map_err(GroupError::Io)?;
Ok(path)
}