use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use datasynth_core::models::{CguImpairmentResult, CguImpairmentTest};
use crate::errors::{GroupError, GroupResult};
use crate::manifest::CguPlan;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CguTestInputs {
pub cgu_id: String,
#[serde(default)]
pub other_carrying: Option<Decimal>,
#[serde(default)]
pub fair_value_less_costs: Option<Decimal>,
#[serde(default)]
pub value_in_use: Option<Decimal>,
#[serde(default)]
pub recoverable_ratio: Option<Decimal>,
}
pub const CGU_IMPAIRMENT_TESTS_FILENAME: &str = "cgu_impairment_tests.json";
pub fn run_cgu_impairment_tests(
cgu_plan: &CguPlan,
test_inputs: &[CguTestInputs],
entity_net_assets: &BTreeMap<String, Decimal>,
test_date: NaiveDate,
currency: &str,
) -> GroupResult<Vec<CguImpairmentResult>> {
if test_inputs.is_empty() {
return Ok(Vec::new());
}
let mut results: Vec<CguImpairmentResult> = Vec::with_capacity(test_inputs.len());
for input in test_inputs {
let cgu = cgu_plan
.cgus
.iter()
.find(|c| c.cgu_id == input.cgu_id)
.ok_or_else(|| {
GroupError::Aggregate(format!(
"cgu impairment: test input references cgu_id `{}` which has no matching definition in the manifest plan",
input.cgu_id,
))
})?;
let allocated_goodwill: Decimal = cgu_plan
.goodwill_allocations
.iter()
.filter(|a| a.cgu_id == input.cgu_id)
.map(|a| a.goodwill_amount)
.sum();
let other_carrying = match input.other_carrying {
Some(c) => c,
None => {
let members_net: Decimal = cgu
.member_entity_codes
.iter()
.map(|e| entity_net_assets.get(e).copied().unwrap_or(Decimal::ZERO))
.sum();
(members_net - allocated_goodwill).max(Decimal::ZERO)
}
};
if other_carrying < Decimal::ZERO {
return Err(GroupError::Aggregate(format!(
"cgu impairment: cgu `{}` has negative other_carrying `{other_carrying}`",
input.cgu_id,
)));
}
let (fair_value_less_costs, value_in_use) = if let Some(ratio) = input.recoverable_ratio {
if ratio < Decimal::ZERO {
return Err(GroupError::Aggregate(format!(
"cgu impairment: cgu `{}` has negative recoverable_ratio `{ratio}`",
input.cgu_id,
)));
}
let recoverable = (allocated_goodwill + other_carrying) * ratio;
(recoverable, recoverable)
} else {
let fvlc = input.fair_value_less_costs.ok_or_else(|| {
GroupError::Aggregate(format!(
"cgu impairment: cgu `{}` needs fair_value_less_costs + value_in_use (or recoverable_ratio)",
input.cgu_id,
))
})?;
let viu = input.value_in_use.ok_or_else(|| {
GroupError::Aggregate(format!(
"cgu impairment: cgu `{}` needs value_in_use (or recoverable_ratio)",
input.cgu_id,
))
})?;
for (label, val) in [("fair_value_less_costs", fvlc), ("value_in_use", viu)] {
if val < Decimal::ZERO {
return Err(GroupError::Aggregate(format!(
"cgu impairment: test input for cgu `{}` has negative {label} `{val}`",
input.cgu_id,
)));
}
}
(fvlc, viu)
};
let test = CguImpairmentTest {
cgu_id: input.cgu_id.clone(),
test_date,
allocated_goodwill,
other_carrying,
fair_value_less_costs,
value_in_use,
currency: currency.to_string(),
};
results.push(test.run());
}
results.sort_by(|a, b| a.cgu_id.cmp(&b.cgu_id));
Ok(results)
}
pub fn write_cgu_impairment_tests(
out_dir: &Path,
results: &[CguImpairmentResult],
) -> GroupResult<Option<PathBuf>> {
if results.is_empty() {
return Ok(None);
}
let dir = out_dir.join(crate::aggregate::translation::cta::CONSOLIDATED_SUBDIR);
std::fs::create_dir_all(&dir).map_err(|e| {
GroupError::Aggregate(format!(
"cgu impairment: cannot create directory `{}`: {e}",
dir.display()
))
})?;
let path = dir.join(CGU_IMPAIRMENT_TESTS_FILENAME);
let json = serde_json::to_string_pretty(results).map_err(|e| {
GroupError::Aggregate(format!(
"cgu impairment: failed to serialise results to JSON: {e}"
))
})?;
std::fs::write(&path, json).map_err(|e| {
GroupError::Aggregate(format!(
"cgu impairment: cannot write `{}`: {e}",
path.display()
))
})?;
Ok(Some(path))
}
#[cfg(test)]
mod tests {
use super::*;
use datasynth_core::models::{CashGeneratingUnit, GoodwillAllocation};
use rust_decimal_macros::dec;
fn date() -> NaiveDate {
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
}
fn plan_with(cgus: Vec<&str>, allocs: Vec<(&str, &str, Decimal)>) -> CguPlan {
CguPlan {
cgus: cgus
.into_iter()
.map(|id| CashGeneratingUnit::new(id, format!("name-{id}"), vec!["E1".to_string()]))
.collect(),
goodwill_allocations: allocs
.into_iter()
.map(|(cgu_id, bc_id, amt)| GoodwillAllocation {
cgu_id: cgu_id.to_string(),
business_combination_id: bc_id.to_string(),
goodwill_amount: amt,
allocation_date: date(),
})
.collect(),
}
}
fn ti(cgu_id: &str, oc: Decimal, fvlc: Decimal, viu: Decimal) -> CguTestInputs {
CguTestInputs {
cgu_id: cgu_id.to_string(),
other_carrying: Some(oc),
fair_value_less_costs: Some(fvlc),
value_in_use: Some(viu),
recoverable_ratio: None,
}
}
#[test]
fn other_carrying_derived_from_member_entity_net_assets() {
let plan = CguPlan {
cgus: vec![CashGeneratingUnit::new(
"CGU_X",
"name",
vec!["E1".to_string(), "E2".to_string()],
)],
goodwill_allocations: vec![GoodwillAllocation {
cgu_id: "CGU_X".to_string(),
business_combination_id: "BC".to_string(),
goodwill_amount: dec!(100),
allocation_date: date(),
}],
};
let net_assets: BTreeMap<String, Decimal> =
[("E1".to_string(), dec!(900)), ("E2".to_string(), dec!(300))]
.into_iter()
.collect();
let inputs = vec![CguTestInputs {
cgu_id: "CGU_X".to_string(),
other_carrying: None, fair_value_less_costs: Some(dec!(2000)),
value_in_use: Some(dec!(0)),
recoverable_ratio: None,
}];
let results = run_cgu_impairment_tests(&plan, &inputs, &net_assets, date(), "EUR").unwrap();
assert_eq!(results[0].carrying_total, dec!(1200));
assert_eq!(results[0].impairment_loss_total, Decimal::ZERO); }
#[test]
fn recoverable_ratio_is_a_coherent_multiple_of_carrying() {
let plan = plan_with(vec!["CGU_X"], vec![("CGU_X", "BC", dec!(100))]);
let inputs = vec![CguTestInputs {
cgu_id: "CGU_X".to_string(),
other_carrying: Some(dec!(900)),
fair_value_less_costs: None,
value_in_use: None,
recoverable_ratio: Some(dec!(0.9)),
}];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
assert_eq!(results[0].carrying_total, dec!(1000));
assert_eq!(results[0].recoverable_amount, dec!(900));
assert_eq!(results[0].impairment_loss_total, dec!(100));
assert_eq!(results[0].impairment_loss_to_goodwill, dec!(100));
}
#[test]
fn empty_inputs_returns_empty_results_no_error() {
let plan = plan_with(vec!["CGU_X"], vec![]);
let results =
run_cgu_impairment_tests(&plan, &[], &BTreeMap::new(), date(), "EUR").unwrap();
assert!(results.is_empty());
}
#[test]
fn happy_path_recoverable_no_impairment() {
let plan = plan_with(vec!["CGU_X"], vec![("CGU_X", "BC_001", dec!(100))]);
let inputs = vec![ti("CGU_X", dec!(500), dec!(800), dec!(700))];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].cgu_id, "CGU_X");
assert_eq!(results[0].carrying_total, dec!(600));
assert_eq!(results[0].recoverable_amount, dec!(800));
assert_eq!(results[0].impairment_loss_total, Decimal::ZERO);
assert_eq!(results[0].impairment_loss_to_goodwill, Decimal::ZERO);
assert_eq!(results[0].impairment_loss_to_other_assets, Decimal::ZERO);
}
#[test]
fn impairment_loss_first_allocated_to_goodwill_per_ias36_para104() {
let plan = plan_with(vec!["CGU_X"], vec![("CGU_X", "BC_001", dec!(100))]);
let inputs = vec![ti("CGU_X", dec!(500), dec!(400), dec!(350))];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
assert_eq!(results[0].impairment_loss_total, dec!(200));
assert_eq!(results[0].impairment_loss_to_goodwill, dec!(100));
assert_eq!(results[0].impairment_loss_to_other_assets, dec!(100));
}
#[test]
fn multiple_allocations_to_same_cgu_sum() {
let plan = plan_with(
vec!["CGU_X"],
vec![
("CGU_X", "BC_001", dec!(50)),
("CGU_X", "BC_002", dec!(30)),
("CGU_X", "BC_003", dec!(20)),
],
);
let inputs = vec![ti("CGU_X", dec!(0), dec!(60), dec!(0))];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
assert_eq!(results[0].carrying_total, dec!(100));
assert_eq!(results[0].impairment_loss_total, dec!(40));
assert_eq!(results[0].impairment_loss_to_goodwill, dec!(40));
assert_eq!(results[0].impairment_loss_to_other_assets, Decimal::ZERO);
}
#[test]
fn cgu_with_no_goodwill_allocation_runs_with_zero_goodwill() {
let plan = plan_with(vec!["CGU_NO_GW"], vec![]);
let inputs = vec![ti("CGU_NO_GW", dec!(1000), dec!(800), dec!(750))];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
assert_eq!(results[0].carrying_total, dec!(1000));
assert_eq!(results[0].impairment_loss_total, dec!(200));
assert_eq!(results[0].impairment_loss_to_goodwill, Decimal::ZERO);
assert_eq!(results[0].impairment_loss_to_other_assets, dec!(200));
}
#[test]
fn unknown_cgu_id_in_inputs_rejected() {
let plan = plan_with(vec!["DEFINED"], vec![]);
let inputs = vec![ti("GHOST", dec!(0), dec!(0), dec!(0))];
let err =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap_err();
assert!(format!("{err}").contains("no matching definition"));
}
#[test]
fn negative_input_rejected() {
let plan = plan_with(vec!["CGU_X"], vec![]);
let inputs = vec![ti("CGU_X", dec!(-1), dec!(0), dec!(0))];
let err =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap_err();
assert!(format!("{err}").contains("negative other_carrying"));
}
#[test]
fn results_sorted_by_cgu_id_for_determinism() {
let plan = plan_with(vec!["CGU_C", "CGU_A", "CGU_B"], vec![]);
let inputs = vec![
ti("CGU_C", dec!(0), dec!(100), dec!(0)),
ti("CGU_A", dec!(0), dec!(100), dec!(0)),
ti("CGU_B", dec!(0), dec!(100), dec!(0)),
];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
let ids: Vec<&str> = results.iter().map(|r| r.cgu_id.as_str()).collect();
assert_eq!(ids, vec!["CGU_A", "CGU_B", "CGU_C"]);
}
#[test]
fn write_skipped_when_results_empty() {
let tmp = tempfile::tempdir().unwrap();
let result = write_cgu_impairment_tests(tmp.path(), &[]).unwrap();
assert!(result.is_none());
let dir = tmp.path().join("consolidated");
assert!(
!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(),
"consolidated/ should be empty when no impairment results"
);
}
#[test]
fn write_emits_pretty_json_file_at_canonical_path() {
let tmp = tempfile::tempdir().unwrap();
let plan = plan_with(vec!["CGU_X"], vec![("CGU_X", "BC_001", dec!(100))]);
let inputs = vec![ti("CGU_X", dec!(500), dec!(400), dec!(350))];
let results =
run_cgu_impairment_tests(&plan, &inputs, &BTreeMap::new(), date(), "EUR").unwrap();
let path = write_cgu_impairment_tests(tmp.path(), &results)
.unwrap()
.expect("must return Some path when results non-empty");
assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some("cgu_impairment_tests.json")
);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("\"cgu_id\": \"CGU_X\""));
assert!(content.contains("\"impairment_loss_to_goodwill\""));
}
}