use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::JournalEntry;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UnbalancedJeRecord {
pub document_id: String,
pub company_code: String,
pub total_debit: String,
pub total_credit: String,
pub difference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AccountActivity {
pub gl_account: String,
pub debit_total: String,
pub credit_total: String,
pub net: String,
pub line_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TbPeriodCheck {
pub fiscal_year: u16,
pub fiscal_period: u8,
pub total_debit_balance: String,
pub total_credit_balance: String,
pub difference: String,
pub balanced: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LedgerCoherenceReport {
pub je_count: usize,
pub line_count: usize,
pub balanced_je_count: usize,
pub unbalanced_je_count: usize,
pub unbalanced_jes: Vec<UnbalancedJeRecord>,
pub all_jes_balanced: bool,
pub total_debits: String,
pub total_credits: String,
pub ledger_nets_to_zero: bool,
pub distinct_accounts: usize,
pub distinct_companies: usize,
pub period_span: Option<[String; 2]>,
pub top_account_activity: Vec<AccountActivity>,
pub tb_period_checks: Vec<TbPeriodCheck>,
pub all_tbs_balanced: bool,
}
impl LedgerCoherenceReport {
pub const DEFAULT_LIST_CAP: usize = 200;
pub fn from_entries(entries: &[JournalEntry], list_cap: usize) -> Self {
use std::collections::{BTreeMap, BTreeSet};
let mut line_count = 0usize;
let mut balanced = 0usize;
let mut unbalanced: Vec<UnbalancedJeRecord> = Vec::new();
let mut total_debits = Decimal::ZERO;
let mut total_credits = Decimal::ZERO;
let mut companies: BTreeSet<&str> = BTreeSet::new();
let mut activity: BTreeMap<String, (Decimal, Decimal, usize)> = BTreeMap::new();
let mut min_date = None;
let mut max_date = None;
for je in entries {
line_count += je.lines.len();
let td = je.total_debit();
let tc = je.total_credit();
total_debits += td;
total_credits += tc;
companies.insert(je.header.company_code.as_str());
let d = je.header.posting_date;
min_date = Some(min_date.map_or(d, |m: chrono::NaiveDate| m.min(d)));
max_date = Some(max_date.map_or(d, |m: chrono::NaiveDate| m.max(d)));
if je.is_balanced() {
balanced += 1;
} else if unbalanced.len() < list_cap {
unbalanced.push(UnbalancedJeRecord {
document_id: je.header.document_id.to_string(),
company_code: je.header.company_code.clone(),
total_debit: td.to_string(),
total_credit: tc.to_string(),
difference: je.balance_difference().to_string(),
});
}
for line in &je.lines {
let e = activity.entry(line.gl_account.clone()).or_insert((
Decimal::ZERO,
Decimal::ZERO,
0,
));
e.0 += line.debit_amount;
e.1 += line.credit_amount;
e.2 += 1;
}
}
let unbalanced_je_count = entries.len() - balanced;
let distinct_accounts = activity.len();
let mut acts: Vec<AccountActivity> = activity
.into_iter()
.map(|(acct, (d, c, n))| AccountActivity {
gl_account: acct,
debit_total: d.to_string(),
credit_total: c.to_string(),
net: (d - c).to_string(),
line_count: n,
})
.collect();
acts.sort_by(|a, b| {
let an = a.net.parse::<Decimal>().unwrap_or_default().abs();
let bn = b.net.parse::<Decimal>().unwrap_or_default().abs();
bn.cmp(&an)
});
acts.truncate(list_cap);
let period_span = match (min_date, max_date) {
(Some(a), Some(b)) => Some([a.to_string(), b.to_string()]),
_ => None,
};
Self {
je_count: entries.len(),
line_count,
balanced_je_count: balanced,
unbalanced_je_count,
unbalanced_jes: unbalanced,
all_jes_balanced: unbalanced_je_count == 0,
total_debits: total_debits.to_string(),
total_credits: total_credits.to_string(),
ledger_nets_to_zero: total_debits == total_credits,
distinct_accounts,
distinct_companies: companies.len(),
period_span,
top_account_activity: acts,
tb_period_checks: Vec::new(),
all_tbs_balanced: true,
}
}
pub fn with_tb_checks(
mut self,
tbs: impl IntoIterator<Item = (u16, u8, Decimal, Decimal)>,
) -> Self {
let mut all_balanced = true;
for (fy, fp, td, tc) in tbs {
let diff = td - tc;
let balanced = diff.is_zero();
all_balanced &= balanced;
self.tb_period_checks.push(TbPeriodCheck {
fiscal_year: fy,
fiscal_period: fp,
total_debit_balance: td.to_string(),
total_credit_balance: tc.to_string(),
difference: diff.to_string(),
balanced,
});
}
self.all_tbs_balanced = all_balanced;
self
}
pub fn is_coherent(&self) -> bool {
self.all_jes_balanced && self.ledger_nets_to_zero && self.all_tbs_balanced
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::journal_entry::{JournalEntry, JournalEntryHeader, JournalEntryLine};
use chrono::NaiveDate;
fn je(company: &str, lines: Vec<(&str, i64, i64)>) -> JournalEntry {
let mut e = JournalEntry::new(JournalEntryHeader::new(
company.to_string(),
NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
));
for (i, (acct, dr, cr)) in lines.into_iter().enumerate() {
let ln = if dr != 0 {
JournalEntryLine::debit(
e.header.document_id,
(i + 1) as u32,
acct.to_string(),
Decimal::from(dr),
)
} else {
JournalEntryLine::credit(
e.header.document_id,
(i + 1) as u32,
acct.to_string(),
Decimal::from(cr),
)
};
e.add_line(ln);
}
e
}
#[test]
fn balanced_ledger_is_coherent() {
let entries = vec![
je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)]),
je("1000", vec![("5000", 500, 0), ("2000", 0, 500)]),
];
let r = LedgerCoherenceReport::from_entries(&entries, 200);
assert_eq!(r.je_count, 2);
assert_eq!(r.line_count, 4);
assert_eq!(r.balanced_je_count, 2);
assert!(r.all_jes_balanced);
assert!(r.ledger_nets_to_zero);
assert_eq!(r.distinct_accounts, 4);
assert!(r.is_coherent());
assert!(r.unbalanced_jes.is_empty());
}
#[test]
fn unbalanced_je_is_flagged() {
let mut e = JournalEntry::new(JournalEntryHeader::new(
"1000".to_string(),
NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
));
e.add_line(JournalEntryLine::debit(
e.header.document_id,
1,
"4000".into(),
Decimal::from(1000),
));
e.add_line(JournalEntryLine::credit(
e.header.document_id,
2,
"1100".into(),
Decimal::from(995),
));
let entries = vec![e];
let r = LedgerCoherenceReport::from_entries(&entries, 200);
assert!(!r.all_jes_balanced);
assert_eq!(r.unbalanced_je_count, 1);
assert_eq!(r.unbalanced_jes.len(), 1);
assert_eq!(r.unbalanced_jes[0].difference, "5");
assert!(!r.is_coherent());
}
#[test]
fn tb_checks_detect_imbalance() {
let entries = vec![je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)])];
let r = LedgerCoherenceReport::from_entries(&entries, 200).with_tb_checks([
(2026u16, 12u8, Decimal::from(5000), Decimal::from(5000)), (2026u16, 11u8, Decimal::from(5000), Decimal::from(4990)), ]);
assert_eq!(r.tb_period_checks.len(), 2);
assert!(!r.all_tbs_balanced);
assert!(!r.is_coherent());
}
}