use rust_decimal::Decimal;
use datasynth_core::models::balance::AccountType;
use datasynth_core::models::JournalEntry;
use crate::aggregate::equity_method::EquityMethodInvestment;
use crate::aggregate::nci::NciRollforward;
use crate::aggregate::pre_elim::{AggregatedAccount, AggregatedTb};
use crate::errors::{GroupError, GroupResult};
const NCI_EQUITY: &str = "3500";
const EQUITY_METHOD_INVESTMENT: &str = "1850";
const SHARE_OF_PROFIT_OF_ASSOCIATES: &str = "4900";
const RETAINED_EARNINGS: &str = "3300";
pub fn apply_eliminations_to_tb(
pre_elim: &AggregatedTb,
elim_jes: &[JournalEntry],
) -> GroupResult<AggregatedTb> {
let mut post = pre_elim.clone();
for je in elim_jes {
if !je.header.is_elimination {
continue;
}
if je.header.currency != post.currency {
return Err(GroupError::Aggregate(format!(
"apply_eliminations_to_tb: JE currency `{}` ≠ pre-elim currency \
`{}` — translation needed first (Chunk 6)",
je.header.currency, post.currency,
)));
}
for line in &je.lines {
apply_line_to_account(
&mut post,
&line.gl_account,
line.debit_amount,
line.credit_amount,
);
}
}
let (total_debits, total_credits) = recompute_totals(&post);
post.total_debits = total_debits;
post.total_credits = total_credits;
verify_balance_invariant(&post)?;
Ok(post)
}
pub fn apply_nci_and_equity_method(
post_elim_tb: &AggregatedTb,
nci_rollforwards: &[NciRollforward],
equity_method_investments: &[EquityMethodInvestment],
) -> GroupResult<AggregatedTb> {
let mut overlay = post_elim_tb.clone();
for rf in nci_rollforwards {
if rf.currency != overlay.currency {
return Err(GroupError::Aggregate(format!(
"apply_nci_and_equity_method: NCI rollforward for entity \
`{}` is denominated in `{}` but consolidated TB is in \
`{}` — translation needed first (Chunk 6)",
rf.entity_code, rf.currency, overlay.currency,
)));
}
}
for inv in equity_method_investments {
if inv.currency != overlay.currency {
return Err(GroupError::Aggregate(format!(
"apply_nci_and_equity_method: equity-method investment for \
investee `{}` is denominated in `{}` but consolidated TB \
is in `{}` — translation needed first (Chunk 6)",
inv.investee_code, inv.currency, overlay.currency,
)));
}
}
let total_closing_nci: Decimal = nci_rollforwards
.iter()
.map(|rf| rf.closing_nci)
.fold(Decimal::ZERO, |acc, v| acc + v);
if total_closing_nci != Decimal::ZERO {
apply_line_to_account(&mut overlay, NCI_EQUITY, Decimal::ZERO, total_closing_nci);
apply_line_to_account(
&mut overlay,
RETAINED_EARNINGS,
total_closing_nci,
Decimal::ZERO,
);
}
for inv in equity_method_investments {
if inv.closing_carrying_value != Decimal::ZERO {
apply_line_to_account(
&mut overlay,
EQUITY_METHOD_INVESTMENT,
inv.closing_carrying_value,
Decimal::ZERO,
);
apply_line_to_account(
&mut overlay,
RETAINED_EARNINGS,
Decimal::ZERO,
inv.closing_carrying_value,
);
}
if inv.share_of_profit != Decimal::ZERO {
apply_line_to_account(
&mut overlay,
SHARE_OF_PROFIT_OF_ASSOCIATES,
Decimal::ZERO,
inv.share_of_profit,
);
apply_line_to_account(
&mut overlay,
RETAINED_EARNINGS,
inv.share_of_profit,
Decimal::ZERO,
);
}
}
let (total_debits, total_credits) = recompute_totals(&overlay);
overlay.total_debits = total_debits;
overlay.total_credits = total_credits;
verify_balance_invariant(&overlay)?;
Ok(overlay)
}
fn apply_line_to_account(
post: &mut AggregatedTb,
account_code: &str,
debit_amount: Decimal,
credit_amount: Decimal,
) {
let entry = post
.account_totals
.entry(account_code.to_string())
.or_insert_with(|| AggregatedAccount {
account_code: account_code.to_string(),
debit_total: Decimal::ZERO,
credit_total: Decimal::ZERO,
net_balance: Decimal::ZERO,
contributing_entities: 0,
account_type: AccountType::default(),
});
entry.debit_total += debit_amount;
entry.credit_total += credit_amount;
entry.net_balance = entry.debit_total - entry.credit_total;
}
fn recompute_totals(post: &AggregatedTb) -> (Decimal, Decimal) {
let mut td = Decimal::ZERO;
let mut tc = Decimal::ZERO;
for entry in post.account_totals.values() {
td += entry.debit_total;
tc += entry.credit_total;
}
(td, tc)
}
fn verify_balance_invariant(post: &AggregatedTb) -> GroupResult<()> {
let diff = post.total_debits - post.total_credits;
let tolerance = Decimal::new(1, 2); if diff.abs() > tolerance {
tracing::debug!(
total_debits = %post.total_debits,
total_credits = %post.total_credits,
diff = %diff,
"post-elim TB unbalanced (expected — input TBs carry fraud/anomaly imbalance)",
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use chrono::NaiveDate;
use rust_decimal_macros::dec;
fn empty_aggregated_tb(currency: &str) -> AggregatedTb {
AggregatedTb {
group_id: "TEST_GROUP".to_string(),
currency: currency.to_string(),
as_of_date: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
account_totals: BTreeMap::new(),
contributing_entities: vec!["E1".to_string()],
deferred_entities: Vec::new(),
total_debits: Decimal::ZERO,
total_credits: Decimal::ZERO,
}
}
#[test]
fn apply_line_creates_new_account_from_zero() {
let mut tb = empty_aggregated_tb("CHF");
apply_line_to_account(&mut tb, "9999", dec!(100), Decimal::ZERO);
let acct = tb.account_totals.get("9999").expect("must be created");
assert_eq!(acct.debit_total, dec!(100));
assert_eq!(acct.credit_total, Decimal::ZERO);
assert_eq!(acct.net_balance, dec!(100));
assert_eq!(
acct.contributing_entities, 0,
"elimination must not bump contributing_entities"
);
}
#[test]
fn apply_line_accumulates_into_existing_account() {
let mut tb = empty_aggregated_tb("CHF");
tb.account_totals.insert(
"1100".to_string(),
AggregatedAccount {
account_code: "1100".to_string(),
debit_total: dec!(500),
credit_total: Decimal::ZERO,
net_balance: dec!(500),
contributing_entities: 2,
account_type: Default::default(),
},
);
apply_line_to_account(&mut tb, "1100", Decimal::ZERO, dec!(200));
let acct = tb.account_totals.get("1100").unwrap();
assert_eq!(acct.debit_total, dec!(500));
assert_eq!(acct.credit_total, dec!(200));
assert_eq!(acct.net_balance, dec!(300));
assert_eq!(
acct.contributing_entities, 2,
"preserve existing contributing_entities count"
);
}
#[test]
fn recompute_totals_sums_the_per_account_view() {
let mut tb = empty_aggregated_tb("CHF");
tb.account_totals.insert(
"1100".to_string(),
AggregatedAccount {
account_code: "1100".to_string(),
debit_total: dec!(1000),
credit_total: Decimal::ZERO,
net_balance: dec!(1000),
contributing_entities: 1,
account_type: Default::default(),
},
);
tb.account_totals.insert(
"3100".to_string(),
AggregatedAccount {
account_code: "3100".to_string(),
debit_total: Decimal::ZERO,
credit_total: dec!(1000),
net_balance: dec!(-1000),
contributing_entities: 1,
account_type: Default::default(),
},
);
let (td, tc) = recompute_totals(&tb);
assert_eq!(td, dec!(1000));
assert_eq!(tc, dec!(1000));
}
#[test]
fn verify_balance_invariant_passes_on_balanced_tb() {
let mut tb = empty_aggregated_tb("CHF");
tb.total_debits = dec!(500);
tb.total_credits = dec!(500);
verify_balance_invariant(&tb).expect("balanced must pass");
}
#[test]
fn verify_balance_invariant_passes_on_within_tolerance() {
let mut tb = empty_aggregated_tb("CHF");
tb.total_debits = dec!(500);
tb.total_credits = dec!(500.005);
verify_balance_invariant(&tb).expect("within tolerance must pass");
}
#[test]
fn verify_balance_invariant_logs_unbalanced_tb_but_does_not_error() {
let mut tb = empty_aggregated_tb("CHF");
tb.total_debits = dec!(500);
tb.total_credits = dec!(400);
verify_balance_invariant(&tb)
.expect("unbalanced must pass under v5.0 fraud-tolerance contract");
}
}