use std::collections::BTreeSet;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use datasynth_core::models::{CashGeneratingUnit, GoodwillAllocation};
use crate::config::CguConfig;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::expansion::ExpandedEntity;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CguPlan {
pub cgus: Vec<CashGeneratingUnit>,
pub goodwill_allocations: Vec<GoodwillAllocation>,
}
pub fn build_cgu_plan(cfg: &CguConfig, entities: &[ExpandedEntity]) -> GroupResult<CguPlan> {
if cfg.cgus.is_empty() && cfg.goodwill_allocations.is_empty() {
return Ok(CguPlan::default());
}
let entity_codes: BTreeSet<&str> = entities.iter().map(|e| e.code.as_str()).collect();
let mut seen_cgu_ids: BTreeSet<&str> = BTreeSet::new();
for cgu in &cfg.cgus {
if !seen_cgu_ids.insert(cgu.cgu_id.as_str()) {
return Err(GroupError::Config(format!(
"cgu plan: duplicate cgu_id `{}` — every CGU must have a unique id",
cgu.cgu_id,
)));
}
if cgu.member_entity_codes.is_empty() {
return Err(GroupError::Config(format!(
"cgu plan: cgu `{}` has no member_entity_codes — a CGU must aggregate at least one entity's cash flows",
cgu.cgu_id,
)));
}
for member in &cgu.member_entity_codes {
if !entity_codes.contains(member.as_str()) {
return Err(GroupError::Config(format!(
"cgu plan: cgu `{}` references member entity `{}` which is not present in the ownership graph",
cgu.cgu_id, member,
)));
}
}
}
for alloc in &cfg.goodwill_allocations {
if !seen_cgu_ids.contains(alloc.cgu_id.as_str()) {
return Err(GroupError::Config(format!(
"cgu plan: goodwill allocation references cgu_id `{}` (BC `{}`) which has no matching CGU definition",
alloc.cgu_id, alloc.business_combination_id,
)));
}
if alloc.goodwill_amount < Decimal::ZERO {
return Err(GroupError::Config(format!(
"cgu plan: goodwill allocation for cgu `{}` (BC `{}`) has negative goodwill_amount `{}` — bargain purchases produce no allocation row",
alloc.cgu_id, alloc.business_combination_id, alloc.goodwill_amount,
)));
}
}
let mut cgus: Vec<CashGeneratingUnit> = cfg
.cgus
.iter()
.map(|c| {
let cgu = CashGeneratingUnit::new(
c.cgu_id.clone(),
c.name.clone(),
c.member_entity_codes.clone(),
);
match &c.segment_code {
Some(seg) => cgu.with_segment(seg.clone()),
None => cgu,
}
})
.collect();
cgus.sort_by(|a, b| a.cgu_id.cmp(&b.cgu_id));
let mut goodwill_allocations: Vec<GoodwillAllocation> = cfg
.goodwill_allocations
.iter()
.map(|a| GoodwillAllocation {
cgu_id: a.cgu_id.clone(),
business_combination_id: a.business_combination_id.clone(),
goodwill_amount: a.goodwill_amount,
allocation_date: a.allocation_date,
})
.collect();
goodwill_allocations.sort_by(|a, b| {
a.cgu_id
.cmp(&b.cgu_id)
.then_with(|| a.business_combination_id.cmp(&b.business_combination_id))
});
Ok(CguPlan {
cgus,
goodwill_allocations,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CguDefinitionEntry, CguGoodwillAllocationEntry, ConsolidationMethod};
use crate::manifest::expansion::EntitySource;
use chrono::NaiveDate;
use datasynth_core::models::HyperinflationStatus;
use rust_decimal_macros::dec;
fn entity(code: &str) -> ExpandedEntity {
ExpandedEntity {
code: code.to_string(),
name: None,
country: "DE".to_string(),
functional_currency: "EUR".to_string(),
scoping_profile: "significant".to_string(),
consolidation_method: ConsolidationMethod::Full,
ownership_percent: Some(dec!(0.80)),
parent_code: Some("PARENT".to_string()),
accounting_framework: None,
industry: None,
hyperinflation_status: HyperinflationStatus::NotHyperinflationary,
ownership_changes: Vec::new(),
source: EntitySource::Explicit,
generated_block_index: None,
rows: None,
}
}
fn date() -> NaiveDate {
NaiveDate::from_ymd_opt(2023, 6, 30).unwrap()
}
#[test]
fn empty_config_produces_empty_plan() {
let plan = build_cgu_plan(&CguConfig::default(), &[]).unwrap();
assert!(plan.cgus.is_empty());
assert!(plan.goodwill_allocations.is_empty());
}
#[test]
fn happy_path_lifts_definitions_and_allocations() {
let cfg = CguConfig {
cgus: vec![
CguDefinitionEntry {
cgu_id: "CGU_EMEA".to_string(),
name: "EMEA".to_string(),
member_entity_codes: vec!["E1".to_string(), "E2".to_string()],
segment_code: Some("CONS".to_string()),
},
CguDefinitionEntry {
cgu_id: "CGU_AMER".to_string(),
name: "Americas".to_string(),
member_entity_codes: vec!["E3".to_string()],
segment_code: None,
},
],
goodwill_allocations: vec![CguGoodwillAllocationEntry {
cgu_id: "CGU_EMEA".to_string(),
business_combination_id: "BC_2023_001".to_string(),
goodwill_amount: dec!(150000),
allocation_date: date(),
}],
};
let entities = vec![entity("E1"), entity("E2"), entity("E3")];
let plan = build_cgu_plan(&cfg, &entities).unwrap();
assert_eq!(plan.cgus.len(), 2);
assert_eq!(plan.cgus[0].cgu_id, "CGU_AMER");
assert_eq!(plan.cgus[1].cgu_id, "CGU_EMEA");
assert_eq!(plan.cgus[1].segment_code.as_deref(), Some("CONS"));
assert_eq!(plan.goodwill_allocations.len(), 1);
assert_eq!(plan.goodwill_allocations[0].goodwill_amount, dec!(150000));
}
#[test]
fn duplicate_cgu_id_rejected() {
let cfg = CguConfig {
cgus: vec![
CguDefinitionEntry {
cgu_id: "DUP".to_string(),
name: "first".to_string(),
member_entity_codes: vec!["E1".to_string()],
segment_code: None,
},
CguDefinitionEntry {
cgu_id: "DUP".to_string(),
name: "second".to_string(),
member_entity_codes: vec!["E2".to_string()],
segment_code: None,
},
],
..Default::default()
};
let entities = vec![entity("E1"), entity("E2")];
let err = build_cgu_plan(&cfg, &entities).unwrap_err();
assert!(format!("{err}").contains("duplicate cgu_id"));
}
#[test]
fn empty_members_rejected() {
let cfg = CguConfig {
cgus: vec![CguDefinitionEntry {
cgu_id: "CGU_X".to_string(),
name: "x".to_string(),
member_entity_codes: vec![],
segment_code: None,
}],
..Default::default()
};
let err = build_cgu_plan(&cfg, &[]).unwrap_err();
assert!(format!("{err}").contains("no member_entity_codes"));
}
#[test]
fn unknown_member_entity_rejected() {
let cfg = CguConfig {
cgus: vec![CguDefinitionEntry {
cgu_id: "CGU_X".to_string(),
name: "x".to_string(),
member_entity_codes: vec!["NOPE".to_string()],
segment_code: None,
}],
..Default::default()
};
let err = build_cgu_plan(&cfg, &[entity("E1")]).unwrap_err();
assert!(format!("{err}").contains("not present in the ownership graph"));
}
#[test]
fn allocation_to_undefined_cgu_rejected() {
let cfg = CguConfig {
cgus: vec![CguDefinitionEntry {
cgu_id: "DEFINED".to_string(),
name: "n".to_string(),
member_entity_codes: vec!["E1".to_string()],
segment_code: None,
}],
goodwill_allocations: vec![CguGoodwillAllocationEntry {
cgu_id: "GHOST".to_string(),
business_combination_id: "BC_X".to_string(),
goodwill_amount: dec!(100),
allocation_date: date(),
}],
};
let err = build_cgu_plan(&cfg, &[entity("E1")]).unwrap_err();
assert!(format!("{err}").contains("no matching CGU definition"));
}
#[test]
fn negative_goodwill_rejected() {
let cfg = CguConfig {
cgus: vec![CguDefinitionEntry {
cgu_id: "CGU_X".to_string(),
name: "x".to_string(),
member_entity_codes: vec!["E1".to_string()],
segment_code: None,
}],
goodwill_allocations: vec![CguGoodwillAllocationEntry {
cgu_id: "CGU_X".to_string(),
business_combination_id: "BC_X".to_string(),
goodwill_amount: dec!(-1),
allocation_date: date(),
}],
};
let err = build_cgu_plan(&cfg, &[entity("E1")]).unwrap_err();
assert!(format!("{err}").contains("negative goodwill_amount"));
}
#[test]
fn allocations_sort_deterministically_by_cgu_then_bc() {
let cfg = CguConfig {
cgus: vec![CguDefinitionEntry {
cgu_id: "CGU_X".to_string(),
name: "x".to_string(),
member_entity_codes: vec!["E1".to_string()],
segment_code: None,
}],
goodwill_allocations: vec![
CguGoodwillAllocationEntry {
cgu_id: "CGU_X".to_string(),
business_combination_id: "BC_002".to_string(),
goodwill_amount: dec!(200),
allocation_date: date(),
},
CguGoodwillAllocationEntry {
cgu_id: "CGU_X".to_string(),
business_combination_id: "BC_001".to_string(),
goodwill_amount: dec!(100),
allocation_date: date(),
},
],
};
let plan = build_cgu_plan(&cfg, &[entity("E1")]).unwrap();
assert_eq!(
plan.goodwill_allocations[0].business_combination_id,
"BC_001"
);
assert_eq!(
plan.goodwill_allocations[1].business_combination_id,
"BC_002"
);
}
}