use std::collections::BTreeMap;
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use datasynth_core::models::intercompany::{EliminationEntry, EliminationLine, EliminationType};
use datasynth_core::models::{IcPairId, JournalEntry};
use crate::aggregate::ic_matcher::IcMatchedPair;
use crate::config::IcTransactionType;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::builder::GroupManifest;
use crate::shard::ic_plan::{derive_ic_pair_plans, IcPairPlan};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EliminationResult {
pub entries: Vec<EliminationEntry>,
pub total_debit: Decimal,
pub total_credit: Decimal,
pub by_type_counts: BTreeMap<EliminationType, usize>,
}
pub fn generate_eliminations(
matched: &[IcMatchedPair],
manifest: &GroupManifest,
) -> GroupResult<EliminationResult> {
let mut plan_cache: BTreeMap<String, BTreeMap<IcPairId, IcPairPlan>> = BTreeMap::new();
let mut entries: Vec<EliminationEntry> = Vec::new();
let mut sorted_pairs: Vec<&IcMatchedPair> = matched.iter().collect();
sorted_pairs.sort_by(|a, b| {
a.seller_entity
.cmp(&b.seller_entity)
.then(a.buyer_entity.cmp(&b.buyer_entity))
.then(a.pair_id.cmp(&b.pair_id))
});
for pair in sorted_pairs {
let plan = lookup_plan(
&mut plan_cache,
manifest,
&pair.seller_entity,
&pair.pair_id,
)?;
let amount = elimination_amount(&pair.seller_je, pair, &plan);
let fiscal_period = format_fiscal_period(pair.seller_je.header.posting_date);
for mut entry in build_entries_for_pair(pair, &plan, amount, &fiscal_period, manifest)? {
entry.created_at = pair
.seller_je
.header
.posting_date
.and_hms_opt(0, 0, 0)
.expect("00:00:00 is always a valid time");
verify_balanced(&entry, pair)?;
entries.push(entry);
}
}
entries.sort_by(|a, b| {
elimination_type_order(a.elimination_type)
.cmp(&elimination_type_order(b.elimination_type))
.then_with(|| a.entry_id.cmp(&b.entry_id))
});
let mut total_debit = Decimal::ZERO;
let mut total_credit = Decimal::ZERO;
let mut by_type_counts: BTreeMap<EliminationType, usize> = BTreeMap::new();
for entry in &entries {
total_debit += entry.total_debit;
total_credit += entry.total_credit;
*by_type_counts.entry(entry.elimination_type).or_insert(0) += 1;
}
Ok(EliminationResult {
entries,
total_debit,
total_credit,
by_type_counts,
})
}
fn build_entries_for_pair(
pair: &IcMatchedPair,
plan: &IcPairPlan,
amount: Decimal,
fiscal_period: &str,
manifest: &GroupManifest,
) -> GroupResult<Vec<EliminationEntry>> {
let entry_date = pair.seller_je.header.posting_date;
let consolidation_entity = manifest.group_id.clone();
let currency = manifest.presentation_currency.clone();
let pair_short = short_pair_id(&pair.pair_id);
let tx = plan.transaction_type;
let mut out = Vec::with_capacity(2);
match tx {
IcTransactionType::GoodsSale
| IcTransactionType::ServiceProvided
| IcTransactionType::ManagementFee
| IcTransactionType::Royalty
| IcTransactionType::CostSharing
| IcTransactionType::ExpenseRecharge => {
out.push(build_balance_entry(
pair,
amount,
fiscal_period,
entry_date,
consolidation_entity.clone(),
currency.clone(),
&pair_short,
));
out.push(build_revenue_expense_entry(
pair,
tx,
amount,
fiscal_period,
entry_date,
consolidation_entity,
currency,
&pair_short,
));
}
IcTransactionType::LoanInterest => {
out.push(build_balance_entry(
pair,
amount,
fiscal_period,
entry_date,
consolidation_entity.clone(),
currency.clone(),
&pair_short,
));
out.push(build_interest_entry(
pair,
amount,
fiscal_period,
entry_date,
consolidation_entity,
currency,
&pair_short,
));
}
IcTransactionType::Dividend => {
out.push(build_balance_entry(
pair,
amount,
fiscal_period,
entry_date,
consolidation_entity.clone(),
currency.clone(),
&pair_short,
));
out.push(build_dividend_entry(
pair,
amount,
fiscal_period,
entry_date,
consolidation_entity,
currency,
&pair_short,
));
}
}
Ok(out)
}
fn build_balance_entry(
pair: &IcMatchedPair,
amount: Decimal,
fiscal_period: &str,
entry_date: chrono::NaiveDate,
consolidation_entity: String,
currency: String,
pair_short: &str,
) -> EliminationEntry {
let mut entry = EliminationEntry::create_ic_balance_elimination(
format!("ELIM-BAL-{pair_short}"),
consolidation_entity,
fiscal_period.to_string(),
entry_date,
&pair.seller_entity, &pair.buyer_entity, IC_AR_CLEARING,
IC_AP_CLEARING,
amount,
currency,
);
entry
.ic_references
.push(format!("ic_pair_id={}", pair.pair_id));
entry
}
#[allow(clippy::too_many_arguments)]
fn build_revenue_expense_entry(
pair: &IcMatchedPair,
tx: IcTransactionType,
amount: Decimal,
fiscal_period: &str,
entry_date: chrono::NaiveDate,
consolidation_entity: String,
currency: String,
pair_short: &str,
) -> EliminationEntry {
let revenue_account = IC_REVENUE;
let expense_account = match tx {
IcTransactionType::GoodsSale => COGS_ACCOUNT,
_ => IC_EXPENSE_ACCOUNT,
};
let mut entry = EliminationEntry::create_ic_revenue_expense_elimination(
format!("ELIM-REV-{pair_short}"),
consolidation_entity,
fiscal_period.to_string(),
entry_date,
&pair.seller_entity,
&pair.buyer_entity,
revenue_account,
expense_account,
amount,
currency,
);
entry
.ic_references
.push(format!("ic_pair_id={}", pair.pair_id));
entry
}
fn build_interest_entry(
pair: &IcMatchedPair,
amount: Decimal,
fiscal_period: &str,
entry_date: chrono::NaiveDate,
consolidation_entity: String,
currency: String,
pair_short: &str,
) -> EliminationEntry {
let mut entry = EliminationEntry::new(
format!("ELIM-INT-{pair_short}"),
EliminationType::ICInterest,
consolidation_entity,
fiscal_period.to_string(),
entry_date,
currency.clone(),
);
entry.related_companies = vec![pair.seller_entity.clone(), pair.buyer_entity.clone()];
entry.description = format!(
"Eliminate IC interest between {} and {}",
pair.seller_entity, pair.buyer_entity
);
entry.ic_references = vec![format!("ic_pair_id={}", pair.pair_id)];
entry.add_line(EliminationLine {
line_number: 1,
company: pair.seller_entity.clone(),
account: IC_INTEREST_INCOME.to_string(),
is_debit: true,
amount,
currency: currency.clone(),
description: format!("Eliminate IC interest income from {}", pair.buyer_entity),
});
entry.add_line(EliminationLine {
line_number: 2,
company: pair.buyer_entity.clone(),
account: INTEREST_EXPENSE.to_string(),
is_debit: false,
amount,
currency,
description: format!("Eliminate IC interest expense to {}", pair.seller_entity),
});
entry
}
fn build_dividend_entry(
pair: &IcMatchedPair,
amount: Decimal,
fiscal_period: &str,
entry_date: chrono::NaiveDate,
consolidation_entity: String,
currency: String,
pair_short: &str,
) -> EliminationEntry {
let mut entry = EliminationEntry::new(
format!("ELIM-DIV-{pair_short}"),
EliminationType::ICDividends,
consolidation_entity,
fiscal_period.to_string(),
entry_date,
currency.clone(),
);
entry.related_companies = vec![pair.seller_entity.clone(), pair.buyer_entity.clone()];
entry.description = format!(
"Eliminate IC dividend from {} to {}",
pair.buyer_entity, pair.seller_entity,
);
entry.ic_references = vec![format!("ic_pair_id={}", pair.pair_id)];
entry.add_line(EliminationLine {
line_number: 1,
company: pair.seller_entity.clone(),
account: DIVIDEND_INCOME.to_string(),
is_debit: true,
amount,
currency: currency.clone(),
description: format!("Eliminate dividend income from {}", pair.buyer_entity),
});
entry.add_line(EliminationLine {
line_number: 2,
company: pair.buyer_entity.clone(),
account: RETAINED_EARNINGS.to_string(),
is_debit: false,
amount,
currency,
description: "Restore retained earnings".to_string(),
});
entry
}
fn elimination_amount(
seller_je: &JournalEntry,
pair: &IcMatchedPair,
plan: &crate::shard::IcPairPlan,
) -> Decimal {
if let Some(amt) = seller_je
.lines
.iter()
.find(|l| l.is_debit())
.map(|l| l.debit_amount)
{
return amt;
}
let total_credit: Decimal = seller_je.lines.iter().map(|l| l.credit_amount).sum();
if total_credit > Decimal::ZERO {
tracing::warn!(
target: "datasynth_group::elimination",
pair_id = %pair.pair_id,
seller_entity = %pair.seller_entity,
plan_amount = %plan.amount,
credit_amount = %total_credit,
"IC seller JE has no debit line — falling back to total credit. \
Likely a ReversedAmount anomaly on the IC JE; consider regenerating \
shards with the v5.31 Phase 7 IC-JE anomaly gate."
);
return total_credit;
}
tracing::warn!(
target: "datasynth_group::elimination",
pair_id = %pair.pair_id,
seller_entity = %pair.seller_entity,
plan_amount = %plan.amount,
"IC seller JE has neither debit nor credit lines — falling back to plan.amount. \
Severely corrupted IC JE; the consolidated entry will use the manifest's notional."
);
plan.amount
}
fn lookup_plan(
cache: &mut BTreeMap<String, BTreeMap<IcPairId, IcPairPlan>>,
manifest: &GroupManifest,
entity_code: &str,
pair_id: &IcPairId,
) -> GroupResult<IcPairPlan> {
if !cache.contains_key(entity_code) {
let plans = derive_ic_pair_plans(manifest, entity_code);
let by_pair: BTreeMap<IcPairId, IcPairPlan> =
plans.into_iter().map(|p| (p.pair_id, p)).collect();
cache.insert(entity_code.to_string(), by_pair);
}
let entity_plans = cache.get(entity_code).expect("just inserted");
entity_plans.get(pair_id).cloned().ok_or_else(|| {
GroupError::Aggregate(format!(
"generate_eliminations: entity `{}` is the seller of pair {} but \
the manifest derives no plan with that pair_id for that entity — \
stale shard output or manifest mismatch",
entity_code, pair_id
))
})
}
fn format_fiscal_period(d: chrono::NaiveDate) -> String {
format!("{:04}{:02}", d.year(), d.month())
}
fn short_pair_id(pair_id: &IcPairId) -> String {
pair_id.to_hex()[..8].to_string()
}
fn verify_balanced(entry: &EliminationEntry, pair: &IcMatchedPair) -> GroupResult<()> {
let diff = (entry.total_debit - entry.total_credit).abs();
let tolerance = Decimal::new(1, 2); if diff > tolerance {
return Err(GroupError::Aggregate(format!(
"generate_eliminations: pair {} produced unbalanced {:?} entry \
(total_debit={}, total_credit={}, diff={})",
pair.pair_id, entry.elimination_type, entry.total_debit, entry.total_credit, diff
)));
}
Ok(())
}
fn elimination_type_order(t: EliminationType) -> u8 {
match t {
EliminationType::ICBalances => 0,
EliminationType::ICRevenueExpense => 1,
EliminationType::ICInterest => 2,
EliminationType::ICDividends => 3,
EliminationType::ICLoans => 4,
EliminationType::ICProfitInInventory => 5,
EliminationType::ICProfitInFixedAssets => 6,
EliminationType::InvestmentEquity => 7,
EliminationType::MinorityInterest => 8,
EliminationType::Goodwill => 9,
EliminationType::CurrencyTranslation => 10,
}
}
const IC_AR_CLEARING: &str = "1150";
const IC_AP_CLEARING: &str = "2050";
const IC_REVENUE: &str = "4500";
const COGS_ACCOUNT: &str = "5000";
const IC_EXPENSE_ACCOUNT: &str = "6800";
const IC_INTEREST_INCOME: &str = "7000";
const INTEREST_EXPENSE: &str = "7100";
const DIVIDEND_INCOME: &str = "4900";
const RETAINED_EARNINGS: &str = "3300";
pub fn eliminations_to_journal_entries(
result: &EliminationResult,
) -> Vec<datasynth_core::models::JournalEntry> {
datasynth_generators::elimination_to_journal_entries(&result.entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fiscal_period_zero_pads_month() {
let d = chrono::NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
assert_eq!(format_fiscal_period(d), "202403");
let d = chrono::NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
assert_eq!(format_fiscal_period(d), "202412");
}
#[test]
fn elimination_type_order_is_a_permutation() {
let all = [
EliminationType::ICBalances,
EliminationType::ICRevenueExpense,
EliminationType::ICInterest,
EliminationType::ICDividends,
EliminationType::ICLoans,
EliminationType::ICProfitInInventory,
EliminationType::ICProfitInFixedAssets,
EliminationType::InvestmentEquity,
EliminationType::MinorityInterest,
EliminationType::Goodwill,
EliminationType::CurrencyTranslation,
];
let mut keys: Vec<u8> = all.iter().map(|t| elimination_type_order(*t)).collect();
keys.sort();
keys.dedup();
assert_eq!(keys.len(), all.len(), "ordinals must be unique");
}
#[test]
fn short_pair_id_is_eight_hex_chars() {
let id = IcPairId::from_bytes([0xab; 32]);
let s = short_pair_id(&id);
assert_eq!(s.len(), 8);
assert_eq!(s, "abababab");
}
}