use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use datasynth_core::models::balance::{AccountType, TrialBalance};
use datasynth_standards::framework::AccountingFramework;
use crate::aggregate::translation::classify::{classify_account, TranslationAccountType};
use crate::aggregate::translation::restatement::IndexedRestatement;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::FxRateMaster;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DrCr {
Debit,
Credit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RateBasis {
Closing,
Historical,
Average,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslatedLine {
pub account_code: String,
pub local_amount: Decimal,
pub local_dr_cr: DrCr,
pub fx_rate: Decimal,
pub rate_basis: RateBasis,
pub translated_amount: Decimal,
pub account_type: TranslationAccountType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslatedTb {
pub entity_code: String,
pub functional_currency: String,
pub presentation_currency: String,
pub as_of_date: NaiveDate,
pub lines: Vec<TranslatedLine>,
pub total_translated_debits: Decimal,
pub total_translated_credits: Decimal,
pub cta: Decimal,
}
pub fn translate_entity_tb(
entity_tb: &TrialBalance,
entity_functional_ccy: &str,
fx_rate_master: &FxRateMaster,
period_end: NaiveDate,
presentation_currency: &str,
framework: AccountingFramework,
) -> GroupResult<TranslatedTb> {
translate_entity_tb_with_hyperinflation(
entity_tb,
entity_functional_ccy,
fx_rate_master,
period_end,
presentation_currency,
framework,
datasynth_core::models::HyperinflationStatus::NotHyperinflationary,
)
}
pub fn translate_entity_tb_with_hyperinflation(
entity_tb: &TrialBalance,
entity_functional_ccy: &str,
fx_rate_master: &FxRateMaster,
period_end: NaiveDate,
presentation_currency: &str,
framework: AccountingFramework,
hyperinflation: datasynth_core::models::HyperinflationStatus,
) -> GroupResult<TranslatedTb> {
translate_entity_tb_with_indexed_restatement(
entity_tb,
entity_functional_ccy,
fx_rate_master,
period_end,
presentation_currency,
framework,
hyperinflation,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn translate_entity_tb_with_indexed_restatement(
entity_tb: &TrialBalance,
entity_functional_ccy: &str,
fx_rate_master: &FxRateMaster,
period_end: NaiveDate,
presentation_currency: &str,
framework: AccountingFramework,
hyperinflation: datasynth_core::models::HyperinflationStatus,
restatement: Option<&IndexedRestatement>,
) -> GroupResult<TranslatedTb> {
let identity = entity_functional_ccy == presentation_currency;
let pair_key = format!("{entity_functional_ccy}/{presentation_currency}");
let force_closing = hyperinflation.requires_restatement();
let mut lines = Vec::with_capacity(entity_tb.lines.len());
let mut total_dr = Decimal::ZERO;
let mut total_cr = Decimal::ZERO;
for tbl in &entity_tb.lines {
let account_type = classify_account(&tbl.account_code, framework);
let rate_basis = if force_closing {
RateBasis::Closing
} else {
rate_basis_for(account_type)
};
let (raw_local_amount, local_dr_cr) = direction_and_amount(tbl);
let restatement_factor = restatement
.map(|r| r.factor_for(account_type))
.unwrap_or(Decimal::ONE);
let local_amount = (raw_local_amount * restatement_factor).round_dp(2);
let fx_rate = if identity {
Decimal::ONE
} else {
lookup_rate(fx_rate_master, &pair_key, rate_basis, period_end)?
};
let translated_amount = (local_amount * fx_rate).round_dp(2);
match local_dr_cr {
DrCr::Debit => total_dr += translated_amount,
DrCr::Credit => total_cr += translated_amount,
}
lines.push(TranslatedLine {
account_code: tbl.account_code.clone(),
local_amount,
local_dr_cr,
fx_rate,
rate_basis,
translated_amount,
account_type,
});
}
let cta = total_dr - total_cr;
Ok(TranslatedTb {
entity_code: entity_tb.company_code.clone(),
functional_currency: entity_functional_ccy.to_string(),
presentation_currency: presentation_currency.to_string(),
as_of_date: entity_tb.as_of_date,
lines,
total_translated_debits: total_dr,
total_translated_credits: total_cr,
cta,
})
}
fn rate_basis_for(ty: TranslationAccountType) -> RateBasis {
match ty {
TranslationAccountType::BsMonetary => RateBasis::Closing,
TranslationAccountType::BsNonMonetary | TranslationAccountType::Equity => {
RateBasis::Historical
}
TranslationAccountType::PlRevenue
| TranslationAccountType::PlExpense
| TranslationAccountType::PlOci => RateBasis::Average,
}
}
fn direction_and_amount(
tbl: &datasynth_core::models::balance::TrialBalanceLine,
) -> (Decimal, DrCr) {
if tbl.debit_balance > Decimal::ZERO {
(tbl.debit_balance, DrCr::Debit)
} else if tbl.credit_balance > Decimal::ZERO {
(tbl.credit_balance, DrCr::Credit)
} else {
let dir = match tbl.account_type {
AccountType::Liability
| AccountType::Equity
| AccountType::Revenue
| AccountType::ContraAsset => DrCr::Credit,
_ => DrCr::Debit,
};
(Decimal::ZERO, dir)
}
}
fn lookup_rate(
master: &FxRateMaster,
pair_key: &str,
basis: RateBasis,
period_end: NaiveDate,
) -> GroupResult<Decimal> {
let rate = match basis {
RateBasis::Closing => master.closing_by_pair.get(pair_key).copied(),
RateBasis::Historical | RateBasis::Average => master.average_by_pair.get(pair_key).copied(),
};
rate.ok_or_else(|| {
GroupError::Aggregate(format!(
"translate_entity_tb: missing FX rate {pair_key} at {period_end} (basis={basis:?})"
))
})
}