1use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15use super::JournalEntry;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct UnbalancedJeRecord {
20 pub document_id: String,
21 pub company_code: String,
22 pub total_debit: String,
24 pub total_credit: String,
25 pub difference: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct AccountActivity {
31 pub gl_account: String,
32 pub debit_total: String,
33 pub credit_total: String,
34 pub net: String,
35 pub line_count: usize,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct TbPeriodCheck {
41 pub fiscal_year: u16,
42 pub fiscal_period: u8,
43 pub total_debit_balance: String,
44 pub total_credit_balance: String,
45 pub difference: String,
46 pub balanced: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct LedgerCoherenceReport {
52 pub je_count: usize,
53 pub line_count: usize,
54 pub balanced_je_count: usize,
55 pub unbalanced_je_count: usize,
56 pub unbalanced_jes: Vec<UnbalancedJeRecord>,
58 pub all_jes_balanced: bool,
59 pub total_debits: String,
60 pub total_credits: String,
61 pub ledger_nets_to_zero: bool,
63 pub distinct_accounts: usize,
64 pub distinct_companies: usize,
65 pub period_span: Option<[String; 2]>,
67 pub top_account_activity: Vec<AccountActivity>,
69 pub tb_period_checks: Vec<TbPeriodCheck>,
71 pub all_tbs_balanced: bool,
74}
75
76impl LedgerCoherenceReport {
77 pub const DEFAULT_LIST_CAP: usize = 200;
79
80 pub fn from_entries(entries: &[JournalEntry], list_cap: usize) -> Self {
83 use std::collections::{BTreeMap, BTreeSet};
84
85 let mut line_count = 0usize;
86 let mut balanced = 0usize;
87 let mut unbalanced: Vec<UnbalancedJeRecord> = Vec::new();
88 let mut total_debits = Decimal::ZERO;
89 let mut total_credits = Decimal::ZERO;
90 let mut companies: BTreeSet<&str> = BTreeSet::new();
91 let mut activity: BTreeMap<String, (Decimal, Decimal, usize)> = BTreeMap::new();
93 let mut min_date = None;
94 let mut max_date = None;
95
96 for je in entries {
97 line_count += je.lines.len();
98 let td = je.total_debit();
99 let tc = je.total_credit();
100 total_debits += td;
101 total_credits += tc;
102 companies.insert(je.header.company_code.as_str());
103
104 let d = je.header.posting_date;
105 min_date = Some(min_date.map_or(d, |m: chrono::NaiveDate| m.min(d)));
106 max_date = Some(max_date.map_or(d, |m: chrono::NaiveDate| m.max(d)));
107
108 if je.is_balanced() {
109 balanced += 1;
110 } else if unbalanced.len() < list_cap {
111 unbalanced.push(UnbalancedJeRecord {
112 document_id: je.header.document_id.to_string(),
113 company_code: je.header.company_code.clone(),
114 total_debit: td.to_string(),
115 total_credit: tc.to_string(),
116 difference: je.balance_difference().to_string(),
117 });
118 }
119
120 for line in &je.lines {
121 let e = activity.entry(line.gl_account.clone()).or_insert((
122 Decimal::ZERO,
123 Decimal::ZERO,
124 0,
125 ));
126 e.0 += line.debit_amount;
127 e.1 += line.credit_amount;
128 e.2 += 1;
129 }
130 }
131
132 let unbalanced_je_count = entries.len() - balanced;
133 let distinct_accounts = activity.len();
134
135 let mut acts: Vec<AccountActivity> = activity
137 .into_iter()
138 .map(|(acct, (d, c, n))| AccountActivity {
139 gl_account: acct,
140 debit_total: d.to_string(),
141 credit_total: c.to_string(),
142 net: (d - c).to_string(),
143 line_count: n,
144 })
145 .collect();
146 acts.sort_by(|a, b| {
147 let an = a.net.parse::<Decimal>().unwrap_or_default().abs();
148 let bn = b.net.parse::<Decimal>().unwrap_or_default().abs();
149 bn.cmp(&an)
150 });
151 acts.truncate(list_cap);
152
153 let period_span = match (min_date, max_date) {
154 (Some(a), Some(b)) => Some([a.to_string(), b.to_string()]),
155 _ => None,
156 };
157
158 Self {
159 je_count: entries.len(),
160 line_count,
161 balanced_je_count: balanced,
162 unbalanced_je_count,
163 unbalanced_jes: unbalanced,
164 all_jes_balanced: unbalanced_je_count == 0,
165 total_debits: total_debits.to_string(),
166 total_credits: total_credits.to_string(),
167 ledger_nets_to_zero: total_debits == total_credits,
168 distinct_accounts,
169 distinct_companies: companies.len(),
170 period_span,
171 top_account_activity: acts,
172 tb_period_checks: Vec::new(),
173 all_tbs_balanced: true,
174 }
175 }
176
177 pub fn with_tb_checks(
180 mut self,
181 tbs: impl IntoIterator<Item = (u16, u8, Decimal, Decimal)>,
182 ) -> Self {
183 let mut all_balanced = true;
184 for (fy, fp, td, tc) in tbs {
185 let diff = td - tc;
186 let balanced = diff.is_zero();
187 all_balanced &= balanced;
188 self.tb_period_checks.push(TbPeriodCheck {
189 fiscal_year: fy,
190 fiscal_period: fp,
191 total_debit_balance: td.to_string(),
192 total_credit_balance: tc.to_string(),
193 difference: diff.to_string(),
194 balanced,
195 });
196 }
197 self.all_tbs_balanced = all_balanced;
198 self
199 }
200
201 pub fn is_coherent(&self) -> bool {
204 self.all_jes_balanced && self.ledger_nets_to_zero && self.all_tbs_balanced
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::models::journal_entry::{JournalEntry, JournalEntryHeader, JournalEntryLine};
212 use chrono::NaiveDate;
213
214 fn je(company: &str, lines: Vec<(&str, i64, i64)>) -> JournalEntry {
215 let mut e = JournalEntry::new(JournalEntryHeader::new(
216 company.to_string(),
217 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
218 ));
219 for (i, (acct, dr, cr)) in lines.into_iter().enumerate() {
220 let ln = if dr != 0 {
221 JournalEntryLine::debit(
222 e.header.document_id,
223 (i + 1) as u32,
224 acct.to_string(),
225 Decimal::from(dr),
226 )
227 } else {
228 JournalEntryLine::credit(
229 e.header.document_id,
230 (i + 1) as u32,
231 acct.to_string(),
232 Decimal::from(cr),
233 )
234 };
235 e.add_line(ln);
236 }
237 e
238 }
239
240 #[test]
241 fn balanced_ledger_is_coherent() {
242 let entries = vec![
243 je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)]),
244 je("1000", vec![("5000", 500, 0), ("2000", 0, 500)]),
245 ];
246 let r = LedgerCoherenceReport::from_entries(&entries, 200);
247 assert_eq!(r.je_count, 2);
248 assert_eq!(r.line_count, 4);
249 assert_eq!(r.balanced_je_count, 2);
250 assert!(r.all_jes_balanced);
251 assert!(r.ledger_nets_to_zero);
252 assert_eq!(r.distinct_accounts, 4);
253 assert!(r.is_coherent());
254 assert!(r.unbalanced_jes.is_empty());
255 }
256
257 #[test]
258 fn unbalanced_je_is_flagged() {
259 let mut e = JournalEntry::new(JournalEntryHeader::new(
261 "1000".to_string(),
262 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
263 ));
264 e.add_line(JournalEntryLine::debit(
265 e.header.document_id,
266 1,
267 "4000".into(),
268 Decimal::from(1000),
269 ));
270 e.add_line(JournalEntryLine::credit(
271 e.header.document_id,
272 2,
273 "1100".into(),
274 Decimal::from(995),
275 ));
276 let entries = vec![e];
277 let r = LedgerCoherenceReport::from_entries(&entries, 200);
278 assert!(!r.all_jes_balanced);
279 assert_eq!(r.unbalanced_je_count, 1);
280 assert_eq!(r.unbalanced_jes.len(), 1);
281 assert_eq!(r.unbalanced_jes[0].difference, "5");
282 assert!(!r.is_coherent());
283 }
284
285 #[test]
286 fn tb_checks_detect_imbalance() {
287 let entries = vec![je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)])];
288 let r = LedgerCoherenceReport::from_entries(&entries, 200).with_tb_checks([
289 (2026u16, 12u8, Decimal::from(5000), Decimal::from(5000)), (2026u16, 11u8, Decimal::from(5000), Decimal::from(4990)), ]);
292 assert_eq!(r.tb_period_checks.len(), 2);
293 assert!(!r.all_tbs_balanced);
294 assert!(!r.is_coherent());
295 }
296}