use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupStructure {
pub parent_entity: String,
pub subsidiaries: Vec<SubsidiaryRelationship>,
pub associates: Vec<AssociateRelationship>,
}
impl GroupStructure {
pub fn new(parent_entity: String) -> Self {
Self {
parent_entity,
subsidiaries: Vec::new(),
associates: Vec::new(),
}
}
pub fn add_subsidiary(&mut self, subsidiary: SubsidiaryRelationship) {
self.subsidiaries.push(subsidiary);
}
pub fn add_associate(&mut self, associate: AssociateRelationship) {
self.associates.push(associate);
}
pub fn entity_count(&self) -> usize {
1 + self.subsidiaries.len() + self.associates.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubsidiaryRelationship {
pub entity_code: String,
pub ownership_percentage: Decimal,
pub voting_rights_percentage: Decimal,
pub consolidation_method: GroupConsolidationMethod,
pub acquisition_date: Option<NaiveDate>,
pub nci_percentage: Decimal,
pub functional_currency: String,
}
impl SubsidiaryRelationship {
pub fn new_full(entity_code: String, functional_currency: String) -> Self {
Self {
entity_code,
ownership_percentage: Decimal::from(100),
voting_rights_percentage: Decimal::from(100),
consolidation_method: GroupConsolidationMethod::FullConsolidation,
acquisition_date: None,
nci_percentage: Decimal::ZERO,
functional_currency,
}
}
pub fn new_with_ownership(
entity_code: String,
ownership_percentage: Decimal,
functional_currency: String,
acquisition_date: Option<NaiveDate>,
) -> Self {
let consolidation_method = GroupConsolidationMethod::from_ownership(ownership_percentage);
let nci_percentage = Decimal::from(100) - ownership_percentage;
Self {
entity_code,
ownership_percentage,
voting_rights_percentage: ownership_percentage,
consolidation_method,
acquisition_date,
nci_percentage,
functional_currency,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupConsolidationMethod {
FullConsolidation,
EquityMethod,
FairValue,
}
impl GroupConsolidationMethod {
pub fn from_ownership(ownership_pct: Decimal) -> Self {
if ownership_pct > Decimal::from(50) {
Self::FullConsolidation
} else if ownership_pct >= Decimal::from(20) {
Self::EquityMethod
} else {
Self::FairValue
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssociateRelationship {
pub entity_code: String,
pub ownership_percentage: Decimal,
pub equity_pickup: Decimal,
}
impl AssociateRelationship {
pub fn new(entity_code: String, ownership_percentage: Decimal) -> Self {
Self {
entity_code,
ownership_percentage,
equity_pickup: Decimal::ZERO,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NciMeasurementMethod {
#[default]
Proportionate,
FullGoodwill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NciMeasurement {
pub entity_code: String,
#[serde(with = "crate::serde_decimal")]
pub nci_percentage: Decimal,
#[serde(default)]
pub method: NciMeasurementMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(with = "crate::serde_decimal::option")]
pub acquisition_date_fair_value: Option<Decimal>,
#[serde(with = "crate::serde_decimal")]
pub nci_share_net_assets: Decimal,
#[serde(with = "crate::serde_decimal")]
pub nci_share_profit: Decimal,
#[serde(with = "crate::serde_decimal")]
pub total_nci: Decimal,
}
impl NciMeasurement {
pub fn compute(
entity_code: String,
nci_percentage: Decimal,
net_assets: Decimal,
net_income: Decimal,
) -> Self {
Self::compute_with_method(
entity_code,
nci_percentage,
net_assets,
net_income,
NciMeasurementMethod::Proportionate,
None,
)
}
pub fn compute_with_method(
entity_code: String,
nci_percentage: Decimal,
net_assets: Decimal,
net_income: Decimal,
method: NciMeasurementMethod,
acquisition_date_fair_value: Option<Decimal>,
) -> Self {
let hundred = Decimal::from(100);
let nci_pct_fraction = nci_percentage / hundred;
let nci_share_net_assets = net_assets * nci_pct_fraction;
let nci_share_profit = net_income * nci_pct_fraction;
let total_nci = match (method, acquisition_date_fair_value) {
(NciMeasurementMethod::FullGoodwill, Some(fv)) => fv,
(NciMeasurementMethod::FullGoodwill, None) => {
tracing::warn!(
entity_code = %entity_code,
"NciMeasurement::compute_with_method: \
FullGoodwill method requested without an \
acquisition_date_fair_value — falling back to \
proportionate computation",
);
nci_share_net_assets
}
(NciMeasurementMethod::Proportionate, _) => nci_share_net_assets,
};
Self {
entity_code,
nci_percentage,
method,
acquisition_date_fair_value,
nci_share_net_assets,
nci_share_profit,
total_nci,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_group_consolidation_method_from_ownership() {
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(100)),
GroupConsolidationMethod::FullConsolidation
);
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(51)),
GroupConsolidationMethod::FullConsolidation
);
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(50)),
GroupConsolidationMethod::EquityMethod
);
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(20)),
GroupConsolidationMethod::EquityMethod
);
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(19)),
GroupConsolidationMethod::FairValue
);
assert_eq!(
GroupConsolidationMethod::from_ownership(dec!(0)),
GroupConsolidationMethod::FairValue
);
}
#[test]
fn nci_compute_legacy_path_uses_proportionate() {
let m =
NciMeasurement::compute("SUB1".to_string(), dec!(25), dec!(1_000_000), dec!(120_000));
assert_eq!(m.method, NciMeasurementMethod::Proportionate);
assert_eq!(m.nci_share_net_assets, dec!(250_000));
assert_eq!(m.nci_share_profit, dec!(30_000));
assert_eq!(m.total_nci, dec!(250_000));
assert!(m.acquisition_date_fair_value.is_none());
}
#[test]
fn nci_compute_full_goodwill_uses_acquisition_date_fair_value() {
let m = NciMeasurement::compute_with_method(
"SUB1".to_string(),
dec!(25),
dec!(1_000_000),
dec!(120_000),
NciMeasurementMethod::FullGoodwill,
Some(dec!(310_000)),
);
assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
assert_eq!(m.acquisition_date_fair_value, Some(dec!(310_000)));
assert_eq!(m.nci_share_net_assets, dec!(250_000));
assert_eq!(m.total_nci, dec!(310_000), "full-goodwill total NCI = FV");
}
#[test]
fn nci_compute_full_goodwill_without_fair_value_falls_back() {
let m = NciMeasurement::compute_with_method(
"SUB1".to_string(),
dec!(40),
dec!(500_000),
dec!(50_000),
NciMeasurementMethod::FullGoodwill,
None,
);
assert_eq!(m.method, NciMeasurementMethod::FullGoodwill);
assert!(m.acquisition_date_fair_value.is_none());
assert_eq!(
m.total_nci,
dec!(200_000),
"missing FV must fall back to proportionate (40% of 500k)"
);
}
#[test]
fn nci_compute_proportionate_with_disclosure_fair_value() {
let m = NciMeasurement::compute_with_method(
"SUB1".to_string(),
dec!(30),
dec!(800_000),
dec!(100_000),
NciMeasurementMethod::Proportionate,
Some(dec!(280_000)),
);
assert_eq!(m.total_nci, dec!(240_000), "proportionate: 30% of 800k");
assert_eq!(m.acquisition_date_fair_value, Some(dec!(280_000)));
}
}