use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::aggregate::fs::account_names::AccountNameDictionary;
use crate::aggregate::pre_elim::{AggregatedAccount, 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_from_account(code, account);
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_from_account(code: &str, account: &AggregatedAccount) -> BsSection {
use datasynth_core::models::balance::AccountType;
let first = code.chars().next();
let n: Option<u32> = code.parse().ok();
match account.account_type {
AccountType::Asset | AccountType::ContraAsset => {
let is_non_current = first == Some('0')
|| matches!(n, Some(1500..=1999))
|| (first == Some('2') && n.is_some_and(|v| (200_000..1_000_000).contains(&v)));
if is_non_current {
BsSection::NonCurrentAsset
} else {
BsSection::CurrentAsset
}
}
AccountType::Liability | AccountType::ContraLiability => {
if matches!(n, Some(2300..=2999)) {
BsSection::NonCurrentLiability
} else {
BsSection::CurrentLiability
}
}
AccountType::Equity | AccountType::ContraEquity => {
if matches!(n, Some(3500..=3599)) {
BsSection::Nci
} else {
BsSection::Equity
}
}
AccountType::Revenue | AccountType::Expense => 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::*;
use datasynth_core::models::balance::AccountType;
fn acct(code: &str, kind: AccountType) -> AggregatedAccount {
AggregatedAccount {
account_code: code.to_string(),
debit_total: Decimal::ZERO,
credit_total: Decimal::ZERO,
net_balance: Decimal::ZERO,
contributing_entities: 0,
account_type: kind,
}
}
#[test]
fn classifier_us_gaap_canonical_ranges() {
let cases = [
("1000", AccountType::Asset, BsSection::CurrentAsset),
("1399", AccountType::Asset, BsSection::CurrentAsset),
("1500", AccountType::Asset, BsSection::NonCurrentAsset),
("1850", AccountType::Asset, BsSection::NonCurrentAsset),
("2000", AccountType::Liability, BsSection::CurrentLiability),
(
"2300",
AccountType::Liability,
BsSection::NonCurrentLiability,
),
("3000", AccountType::Equity, BsSection::Equity),
("3500", AccountType::Equity, BsSection::Nci),
("4000", AccountType::Revenue, BsSection::Excluded),
("6000", AccountType::Expense, BsSection::Excluded),
];
for (code, kind, want) in cases {
assert_eq!(
classify_bs_section_from_account(code, &acct(code, kind)),
want,
"{code} ({kind:?})"
);
}
}
#[test]
fn classifier_skr_german_routes_correctly() {
assert_eq!(
classify_bs_section_from_account("0010", &acct("0010", AccountType::Asset)),
BsSection::NonCurrentAsset,
"SKR 0xxx fixed assets → non-current asset"
);
assert_eq!(
classify_bs_section_from_account("1200", &acct("1200", AccountType::Asset)),
BsSection::CurrentAsset,
"SKR 1xxx current assets → current asset"
);
assert_eq!(
classify_bs_section_from_account("2000", &acct("2000", AccountType::Equity)),
BsSection::Equity,
"SKR 2xxx equity → Equity (NOT CurrentLiability)"
);
assert_eq!(
classify_bs_section_from_account("3000", &acct("3000", AccountType::Liability)),
BsSection::CurrentLiability,
"SKR 3xxx liability → CurrentLiability (NOT Equity)"
);
assert_eq!(
classify_bs_section_from_account("4000", &acct("4000", AccountType::Revenue)),
BsSection::Excluded,
"SKR 4xxx revenue → Excluded"
);
assert_eq!(
classify_bs_section_from_account("6000", &acct("6000", AccountType::Expense)),
BsSection::Excluded,
"SKR 6xxx opex → Excluded"
);
}
#[test]
fn classifier_pcg_french_routes_correctly() {
assert_eq!(
classify_bs_section_from_account("210000", &acct("210000", AccountType::Asset)),
BsSection::NonCurrentAsset,
"PCG 2xxxxx fixed assets → non-current asset"
);
assert_eq!(
classify_bs_section_from_account("411000", &acct("411000", AccountType::Asset)),
BsSection::CurrentAsset,
"PCG 411xxx customer receivables → current asset"
);
assert_eq!(
classify_bs_section_from_account("401000", &acct("401000", AccountType::Liability)),
BsSection::CurrentLiability,
"PCG 401xxx suppliers → current liability"
);
assert_eq!(
classify_bs_section_from_account("512000", &acct("512000", AccountType::Asset)),
BsSection::CurrentAsset,
"PCG 5xxxxx cash → current asset"
);
assert_eq!(
classify_bs_section_from_account("101000", &acct("101000", AccountType::Equity)),
BsSection::Equity,
"PCG 10xxxx capital → Equity"
);
}
#[test]
fn classifier_contra_variants_stay_on_bs() {
assert!(matches!(
classify_bs_section_from_account("1599", &acct("1599", AccountType::ContraAsset)),
BsSection::NonCurrentAsset | BsSection::CurrentAsset
));
assert!(matches!(
classify_bs_section_from_account("2199", &acct("2199", AccountType::ContraLiability)),
BsSection::CurrentLiability | BsSection::NonCurrentLiability
));
assert!(matches!(
classify_bs_section_from_account("3299", &acct("3299", AccountType::ContraEquity)),
BsSection::Equity
));
}
}