use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::aggregate::fs::account_names::AccountNameDictionary;
use crate::aggregate::pre_elim::AggregatedTb;
use crate::errors::GroupResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConsolidatedBalanceSheet {
pub group_id: String,
pub as_of_date: NaiveDate,
pub currency: String,
pub current_assets: Vec<BsLine>,
pub non_current_assets: Vec<BsLine>,
pub current_liabilities: Vec<BsLine>,
pub non_current_liabilities: Vec<BsLine>,
pub equity: Vec<BsLine>,
pub nci: Vec<BsLine>,
pub total_assets: Decimal,
pub total_liabilities: Decimal,
pub total_equity: Decimal,
pub total_nci: Decimal,
pub total_liabilities_plus_equity_plus_nci: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BsLine {
pub account_code: String,
pub account_name: String,
pub amount: Decimal,
}
pub fn build_consolidated_balance_sheet(
post_elim_tb: &AggregatedTb,
group_id: &str,
as_of_date: NaiveDate,
) -> GroupResult<ConsolidatedBalanceSheet> {
build_consolidated_balance_sheet_with_names(
post_elim_tb,
group_id,
as_of_date,
&AccountNameDictionary::default(),
)
}
pub fn build_consolidated_balance_sheet_with_names(
post_elim_tb: &AggregatedTb,
group_id: &str,
as_of_date: NaiveDate,
account_names: &AccountNameDictionary,
) -> GroupResult<ConsolidatedBalanceSheet> {
let mut current_assets: Vec<BsLine> = Vec::new();
let mut non_current_assets: Vec<BsLine> = Vec::new();
let mut current_liabilities: Vec<BsLine> = Vec::new();
let mut non_current_liabilities: Vec<BsLine> = Vec::new();
let mut equity: Vec<BsLine> = Vec::new();
let mut nci: Vec<BsLine> = Vec::new();
for (code, account) in &post_elim_tb.account_totals {
let section = classify_bs_section(code);
let line = match section {
BsSection::CurrentAsset | BsSection::NonCurrentAsset => BsLine {
account_code: code.clone(),
account_name: account_names.get(code),
amount: account.debit_total - account.credit_total,
},
BsSection::CurrentLiability
| BsSection::NonCurrentLiability
| BsSection::Equity
| BsSection::Nci => BsLine {
account_code: code.clone(),
account_name: account_names.get(code),
amount: account.credit_total - account.debit_total,
},
BsSection::Excluded => continue,
};
match section {
BsSection::CurrentAsset => current_assets.push(line),
BsSection::NonCurrentAsset => non_current_assets.push(line),
BsSection::CurrentLiability => current_liabilities.push(line),
BsSection::NonCurrentLiability => non_current_liabilities.push(line),
BsSection::Equity => equity.push(line),
BsSection::Nci => nci.push(line),
BsSection::Excluded => unreachable!(),
}
}
for v in [
&mut current_assets,
&mut non_current_assets,
&mut current_liabilities,
&mut non_current_liabilities,
&mut equity,
&mut nci,
] {
v.sort_by(|a, b| a.account_code.cmp(&b.account_code));
}
let total_assets = sum(¤t_assets) + sum(&non_current_assets);
let total_liabilities = sum(¤t_liabilities) + sum(&non_current_liabilities);
let total_equity = sum(&equity);
let total_nci = sum(&nci);
let total_lpe = total_liabilities + total_equity + total_nci;
let tolerance = Decimal::new(1, 2); let diff = total_assets - total_lpe;
let bs_balances_to_cent = diff.abs() <= tolerance;
if !bs_balances_to_cent {
tracing::debug!(
total_assets = %total_assets,
total_lpe = %total_lpe,
diff = %diff,
"consolidated BS imbalance — input TBs carry fraud/anomaly injection",
);
}
Ok(ConsolidatedBalanceSheet {
group_id: group_id.to_string(),
as_of_date,
currency: post_elim_tb.currency.clone(),
current_assets,
non_current_assets,
current_liabilities,
non_current_liabilities,
equity,
nci,
total_assets,
total_liabilities,
total_equity,
total_nci,
total_liabilities_plus_equity_plus_nci: total_lpe,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BsSection {
CurrentAsset,
NonCurrentAsset,
CurrentLiability,
NonCurrentLiability,
Equity,
Nci,
Excluded,
}
fn classify_bs_section(code: &str) -> BsSection {
let n: u32 = match code.parse() {
Ok(n) => n,
Err(_) => return BsSection::Excluded,
};
match n {
1000..=1399 => BsSection::CurrentAsset,
1500..=1999 => BsSection::NonCurrentAsset,
2000..=2299 => BsSection::CurrentLiability,
2300..=2999 => BsSection::NonCurrentLiability,
3000..=3499 => BsSection::Equity,
3500..=3599 => BsSection::Nci,
1400..=1499 => BsSection::CurrentAsset,
_ => BsSection::Excluded,
}
}
fn sum(lines: &[BsLine]) -> Decimal {
lines
.iter()
.map(|l| l.amount)
.fold(Decimal::ZERO, |acc, v| acc + v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifier_assigns_canonical_ranges() {
assert_eq!(classify_bs_section("1000"), BsSection::CurrentAsset);
assert_eq!(classify_bs_section("1399"), BsSection::CurrentAsset);
assert_eq!(classify_bs_section("1850"), BsSection::NonCurrentAsset);
assert_eq!(classify_bs_section("2000"), BsSection::CurrentLiability);
assert_eq!(classify_bs_section("2300"), BsSection::NonCurrentLiability);
assert_eq!(classify_bs_section("3000"), BsSection::Equity);
assert_eq!(classify_bs_section("3500"), BsSection::Nci);
assert_eq!(classify_bs_section("4000"), BsSection::Excluded);
assert_eq!(classify_bs_section("9999"), BsSection::Excluded);
assert_eq!(classify_bs_section(""), BsSection::Excluded);
}
}