use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::config::ConsolidationMethod;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::ManifestEntity;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NciRollforward {
pub entity_code: String,
pub parent_entity_code: String,
pub ownership_percent: Decimal,
pub nci_percent: Decimal,
pub opening_nci: Decimal,
pub nci_share_of_profit: Decimal,
pub nci_share_of_oci: Decimal,
pub nci_dividends: Decimal,
#[serde(default)]
pub equity_transaction_adjustments: Decimal,
#[serde(default)]
pub pl_remeasurement_gain_or_loss: Decimal,
pub closing_nci: Decimal,
pub period_end: NaiveDate,
pub currency: String,
}
pub struct NciInputs<'a> {
pub entity: &'a ManifestEntity,
pub period_net_income: Decimal,
pub period_oci: Decimal,
pub total_dividends_paid: Decimal,
pub opening_nci: Decimal,
pub acquisition_date_nci_fair_value: Option<Decimal>,
pub ownership_changes: &'a [datasynth_core::models::intercompany::OwnershipChangeEvent],
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub currency: String,
}
pub fn compute_nci_rollforward(inputs: &NciInputs) -> GroupResult<NciRollforward> {
let entity = inputs.entity;
if entity.consolidation_method != ConsolidationMethod::Full {
return Err(GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}` has consolidation_method=\
{:?} — NCI is only meaningful for ConsolidationMethod::Full \
(Parent is wholly owned; EquityMethod / Proportional / \
FairValue use one-line investment treatment)",
entity.code, entity.consolidation_method,
)));
}
let ownership_percent = entity.ownership_percent.ok_or_else(|| {
GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}` is consolidation_method=\
Full but has no ownership_percent set — supply ownership_percent \
< 1.0 or change the method to Parent for wholly-owned",
entity.code,
))
})?;
if ownership_percent >= Decimal::ONE {
return Err(GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}` is consolidation_method=\
Full but has no ownership_percent < 1.0 — use Parent for \
wholly-owned",
entity.code,
)));
}
let parent_entity_code = entity.parent_code.clone().ok_or_else(|| {
GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}` has no parent_code in the \
manifest — every Full subsidiary must declare its parent",
entity.code,
))
})?;
let nci_percent = Decimal::ONE - ownership_percent;
let effective_opening_nci = match (
inputs.opening_nci.is_zero(),
inputs.acquisition_date_nci_fair_value,
) {
(true, Some(fv)) => fv,
_ => inputs.opening_nci,
};
use datasynth_core::models::intercompany::OwnershipChangeType;
let mut equity_transaction_adjustments = Decimal::ZERO;
let mut control_gained_event: Option<
&datasynth_core::models::intercompany::OwnershipChangeEvent,
> = None;
for ev in inputs.ownership_changes {
match ev.event_type {
OwnershipChangeType::ControlIncreased | OwnershipChangeType::ControlDecreased => {
equity_transaction_adjustments -= ev.consideration_paid_or_received;
}
OwnershipChangeType::ControlGained => {
control_gained_event = Some(ev);
}
OwnershipChangeType::ControlLost => {
return Err(GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}`: ControlLost \
mid-period deconsolidation events are not yet supported \
by the rollforward — this is a v5.5+ follow-up.",
entity.code,
)));
}
}
}
let mut time_weight = Decimal::ONE;
let mut pl_remeasurement_gain_or_loss = Decimal::ZERO;
let mut control_gained_opening_override: Option<Decimal> = None;
if let Some(ev) = control_gained_event {
if ev.effective_date < inputs.period_start || ev.effective_date > inputs.period_end {
return Err(GroupError::Aggregate(format!(
"compute_nci_rollforward: entity `{}`: ControlGained \
effective_date {} is outside the period [{}, {}] — \
the manifest builder normally rejects this; surface it \
here as a defence-in-depth check",
entity.code, ev.effective_date, inputs.period_start, inputs.period_end,
)));
}
let total_days = (inputs.period_end - inputs.period_start).num_days() + 1;
let post_days = (inputs.period_end - ev.effective_date).num_days() + 1;
time_weight = Decimal::from(post_days) / Decimal::from(total_days);
if let (Some(fv), Some(carrying)) = (
ev.previously_held_interest_fair_value,
ev.previously_held_interest_carrying,
) {
pl_remeasurement_gain_or_loss = fv - carrying;
}
control_gained_opening_override = ev
.acquisition_date_nci_fair_value
.or(inputs.acquisition_date_nci_fair_value);
}
let nci_share_of_profit = nci_percent * inputs.period_net_income * time_weight;
let nci_share_of_oci = nci_percent * inputs.period_oci * time_weight;
let nci_dividends = nci_percent * inputs.total_dividends_paid * time_weight;
let effective_opening_nci = match control_gained_opening_override {
Some(fv) => fv,
None => effective_opening_nci,
};
let closing_nci = (effective_opening_nci + nci_share_of_profit + nci_share_of_oci
- nci_dividends
+ equity_transaction_adjustments)
.round_dp(2);
Ok(NciRollforward {
entity_code: entity.code.clone(),
parent_entity_code,
ownership_percent,
nci_percent,
opening_nci: effective_opening_nci.round_dp(2),
nci_share_of_profit: nci_share_of_profit.round_dp(2),
nci_share_of_oci: nci_share_of_oci.round_dp(2),
nci_dividends: nci_dividends.round_dp(2),
equity_transaction_adjustments: equity_transaction_adjustments.round_dp(2),
pl_remeasurement_gain_or_loss: pl_remeasurement_gain_or_loss.round_dp(2),
closing_nci,
period_end: inputs.period_end,
currency: inputs.currency.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_entity(
code: &str,
method: ConsolidationMethod,
ownership: Option<Decimal>,
) -> ManifestEntity {
ManifestEntity {
code: code.to_string(),
name: None,
country: "DE".to_string(),
functional_currency: "EUR".to_string(),
scoping_profile: "significant".to_string(),
consolidation_method: method,
ownership_percent: ownership,
parent_code: Some("PARENT".to_string()),
accounting_framework: None,
industry: None,
hyperinflation_status:
datasynth_core::models::HyperinflationStatus::NotHyperinflationary,
ownership_changes: Vec::new(),
entity_seed: "00".to_string(),
shard_id: "S_DEFAULT_0001".to_string(),
}
}
fn period_end() -> NaiveDate {
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
}
#[test]
fn happy_path_eighty_percent_owned() {
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: dec!(200),
total_dividends_paid: dec!(500),
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None,
ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).expect("must succeed");
assert_eq!(rf.entity_code, "SUB");
assert_eq!(rf.parent_entity_code, "PARENT");
assert_eq!(rf.ownership_percent, dec!(0.80));
assert_eq!(rf.nci_percent, dec!(0.20));
assert_eq!(rf.nci_share_of_profit, dec!(200.00));
assert_eq!(rf.nci_share_of_oci, dec!(40.00));
assert_eq!(rf.nci_dividends, dec!(100.00));
assert_eq!(rf.closing_nci, dec!(940.00));
}
#[test]
fn full_goodwill_acquisition_date_fair_value_seeds_period_one_opening() {
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.75)));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: dec!(200),
total_dividends_paid: dec!(400),
opening_nci: Decimal::ZERO,
acquisition_date_nci_fair_value: Some(dec!(850)),
ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).expect("must succeed");
assert_eq!(
rf.opening_nci,
dec!(850.00),
"opening must be seeded from fair value, not zero"
);
assert_eq!(rf.nci_share_of_profit, dec!(250.00));
assert_eq!(rf.nci_share_of_oci, dec!(50.00));
assert_eq!(rf.nci_dividends, dec!(100.00));
assert_eq!(rf.closing_nci, dec!(1050.00));
}
#[test]
fn full_goodwill_fair_value_ignored_when_opening_nci_nonzero() {
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(500),
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: dec!(940), acquisition_date_nci_fair_value: Some(dec!(1000)), ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).expect("must succeed");
assert_eq!(
rf.opening_nci,
dec!(940.00),
"fair value must not override a non-zero opening_nci"
);
assert_eq!(rf.closing_nci, dec!(1040.00));
}
#[test]
fn proportionate_basis_unchanged_when_no_fair_value() {
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: dec!(200),
total_dividends_paid: dec!(500),
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None, ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).expect("must succeed");
assert_eq!(rf.opening_nci, dec!(800.00));
assert_eq!(rf.closing_nci, dec!(940.00));
}
#[test]
fn rejects_parent_method() {
let entity = make_entity("PARENT_CO", ConsolidationMethod::Parent, None);
let inputs = NciInputs {
entity: &entity,
period_net_income: Decimal::ZERO,
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: Decimal::ZERO,
acquisition_date_nci_fair_value: None,
ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let err = compute_nci_rollforward(&inputs).expect_err("must reject parent");
match err {
GroupError::Aggregate(msg) => {
assert!(msg.contains("PARENT_CO"));
assert!(msg.contains("Parent"));
}
other => panic!("expected Aggregate, got {other:?}"),
}
}
fn equity_event(
ty: datasynth_core::models::intercompany::OwnershipChangeType,
before: Decimal,
after: Decimal,
consideration: Decimal,
) -> datasynth_core::models::intercompany::OwnershipChangeEvent {
datasynth_core::models::intercompany::OwnershipChangeEvent {
entity_code: "SUB".to_string(),
parent_entity_code: "PARENT".to_string(),
event_type: ty,
effective_date: NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
ownership_percent_before: before,
ownership_percent_after: after,
previously_held_interest_carrying: None,
previously_held_interest_fair_value: None,
consideration_paid_or_received: consideration,
acquisition_date_nci_fair_value: None,
nci_measurement_method: Default::default(),
currency: "CHF".to_string(),
}
}
#[test]
fn control_increased_shrinks_nci_by_consideration() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let event = equity_event(
OwnershipChangeType::ControlIncreased,
dec!(0.80),
dec!(0.90),
dec!(100),
);
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&event),
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.equity_transaction_adjustments, dec!(-100.00));
assert_eq!(rf.closing_nci, dec!(900.00));
}
#[test]
fn control_decreased_grows_nci_by_consideration_received() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let event = equity_event(
OwnershipChangeType::ControlDecreased,
dec!(0.90),
dec!(0.80),
dec!(-100),
);
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&event),
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.equity_transaction_adjustments, dec!(100.00));
assert_eq!(rf.closing_nci, dec!(1100.00));
}
#[test]
fn multiple_equity_transactions_sum() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.75)));
let events = vec![
equity_event(
OwnershipChangeType::ControlDecreased,
dec!(0.80),
dec!(0.75),
dec!(-50),
),
equity_event(
OwnershipChangeType::ControlIncreased,
dec!(0.75),
dec!(0.78),
dec!(30),
),
];
let inputs = NciInputs {
entity: &entity,
period_net_income: Decimal::ZERO,
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: dec!(500),
acquisition_date_nci_fair_value: None,
ownership_changes: &events,
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.equity_transaction_adjustments, dec!(20.00));
assert_eq!(rf.closing_nci, dec!(520.00));
}
#[test]
fn control_gained_at_period_start_is_full_period_contribution() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let mut ev = equity_event(
OwnershipChangeType::ControlGained,
Decimal::ZERO,
dec!(0.80),
dec!(5000),
);
ev.effective_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
ev.acquisition_date_nci_fair_value = Some(dec!(1000));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(2000),
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: Decimal::ZERO,
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&ev),
period_start: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
period_end: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.opening_nci, dec!(1000));
assert_eq!(rf.nci_share_of_profit, dec!(400.00));
assert_eq!(rf.closing_nci, dec!(1400.00));
assert_eq!(rf.pl_remeasurement_gain_or_loss, Decimal::ZERO);
}
#[test]
fn control_gained_mid_period_pro_rates_profit() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let mut ev = equity_event(
OwnershipChangeType::ControlGained,
Decimal::ZERO,
dec!(0.80),
dec!(5000),
);
ev.effective_date = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
ev.acquisition_date_nci_fair_value = Some(dec!(1000));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(2000),
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: Decimal::ZERO,
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&ev),
period_start: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
period_end: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
let weight = Decimal::from(46) / Decimal::from(91);
let expected_share = (dec!(0.20) * dec!(2000) * weight).round_dp(2);
assert_eq!(rf.opening_nci, dec!(1000));
assert_eq!(rf.nci_share_of_profit, expected_share);
let expected_close = (dec!(1000) + dec!(0.20) * dec!(2000) * weight).round_dp(2);
assert_eq!(rf.closing_nci, expected_close);
}
#[test]
fn control_gained_records_ifrs_3_42_remeasurement_gain() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let mut ev = equity_event(
OwnershipChangeType::ControlGained,
Decimal::ZERO,
dec!(0.80),
dec!(5000),
);
ev.effective_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
ev.previously_held_interest_carrying = Some(dec!(800));
ev.previously_held_interest_fair_value = Some(dec!(1500));
ev.acquisition_date_nci_fair_value = Some(dec!(500));
let inputs = NciInputs {
entity: &entity,
period_net_income: Decimal::ZERO,
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: Decimal::ZERO,
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&ev),
period_start: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
period_end: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.pl_remeasurement_gain_or_loss, dec!(700));
}
#[test]
fn control_lost_mid_period_rejected_with_v55_pointer() {
use datasynth_core::models::intercompany::OwnershipChangeType;
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let event = equity_event(
OwnershipChangeType::ControlLost,
dec!(0.80),
Decimal::ZERO,
dec!(-500),
);
let inputs = NciInputs {
entity: &entity,
period_net_income: Decimal::ZERO,
period_oci: Decimal::ZERO,
total_dividends_paid: Decimal::ZERO,
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None,
ownership_changes: std::slice::from_ref(&event),
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let err = compute_nci_rollforward(&inputs).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("ControlLost"));
assert!(msg.contains("v5.5"));
}
#[test]
fn empty_ownership_changes_byte_identical_to_baseline() {
let entity = make_entity("SUB", ConsolidationMethod::Full, Some(dec!(0.80)));
let inputs = NciInputs {
entity: &entity,
period_net_income: dec!(1000),
period_oci: dec!(200),
total_dividends_paid: dec!(500),
opening_nci: dec!(800),
acquisition_date_nci_fair_value: None,
ownership_changes: &[],
period_start: period_end(),
period_end: period_end(),
currency: "CHF".to_string(),
};
let rf = compute_nci_rollforward(&inputs).unwrap();
assert_eq!(rf.equity_transaction_adjustments, Decimal::ZERO);
assert_eq!(rf.closing_nci, dec!(940.00));
}
}