1use chrono::{Datelike, NaiveDate};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::HashMap;
10
11use datasynth_core::models::balance::{
12 AccountBalance, AccountPeriodActivity, AccountType, BalanceSnapshot,
13};
14use datasynth_core::models::{JournalEntry, JournalEntryLine};
15
16#[derive(Debug, Clone)]
18pub struct BalanceTrackerConfig {
19 pub validate_on_each_entry: bool,
21 pub track_history: bool,
23 pub balance_tolerance: Decimal,
25 pub fail_on_validation_error: bool,
27}
28
29impl Default for BalanceTrackerConfig {
30 fn default() -> Self {
31 Self {
32 validate_on_each_entry: true,
33 track_history: true,
34 balance_tolerance: dec!(0.01),
35 fail_on_validation_error: false,
36 }
37 }
38}
39
40pub struct RunningBalanceTracker {
42 config: BalanceTrackerConfig,
43 balances: HashMap<String, HashMap<String, AccountBalance>>,
45 account_types: HashMap<String, AccountType>,
47 history: HashMap<String, Vec<BalanceHistoryEntry>>,
49 validation_errors: Vec<ValidationError>,
51 stats: TrackerStatistics,
53}
54
55#[derive(Debug, Clone)]
57pub struct BalanceHistoryEntry {
58 pub date: NaiveDate,
59 pub entry_id: String,
60 pub account_code: String,
61 pub previous_balance: Decimal,
62 pub change: Decimal,
63 pub new_balance: Decimal,
64}
65
66#[derive(Debug, Clone)]
68pub struct ValidationError {
69 pub date: NaiveDate,
70 pub company_code: String,
71 pub entry_id: Option<String>,
72 pub error_type: ValidationErrorType,
73 pub message: String,
74 pub details: HashMap<String, Decimal>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ValidationErrorType {
80 UnbalancedEntry,
82 BalanceSheetImbalance,
84 NegativeBalance,
86 UnknownAccount,
88 OutOfOrder,
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct TrackerStatistics {
95 pub entries_processed: u64,
96 pub lines_processed: u64,
97 pub total_debits: Decimal,
98 pub total_credits: Decimal,
99 pub companies_tracked: usize,
100 pub accounts_tracked: usize,
101 pub validation_errors: usize,
102}
103
104impl RunningBalanceTracker {
105 pub fn new(config: BalanceTrackerConfig) -> Self {
107 Self {
108 config,
109 balances: HashMap::new(),
110 account_types: HashMap::new(),
111 history: HashMap::new(),
112 validation_errors: Vec::new(),
113 stats: TrackerStatistics::default(),
114 }
115 }
116
117 pub fn with_defaults() -> Self {
119 Self::new(BalanceTrackerConfig::default())
120 }
121
122 pub fn register_account_type(&mut self, account_code: &str, account_type: AccountType) {
124 self.account_types
125 .insert(account_code.to_string(), account_type);
126 }
127
128 pub fn register_account_types(&mut self, types: &[(String, AccountType)]) {
130 for (code, account_type) in types {
131 self.account_types.insert(code.clone(), *account_type);
132 }
133 }
134
135 pub fn register_from_chart_prefixes(&mut self, prefixes: &[(&str, AccountType)]) {
137 for (prefix, account_type) in prefixes {
138 self.account_types.insert(prefix.to_string(), *account_type);
139 }
140 }
141
142 pub fn initialize_from_snapshot(&mut self, snapshot: &BalanceSnapshot) {
144 let company_balances = self
145 .balances
146 .entry(snapshot.company_code.clone())
147 .or_default();
148
149 for (account_code, balance) in &snapshot.balances {
150 company_balances.insert(account_code.clone(), balance.clone());
151 }
152
153 self.stats.companies_tracked = self.balances.len();
154 self.stats.accounts_tracked = self.balances.values().map(|b| b.len()).sum();
155 }
156
157 pub fn apply_entry(&mut self, entry: &JournalEntry) -> Result<(), ValidationError> {
159 if !entry.is_balanced() {
161 let error = ValidationError {
162 date: entry.posting_date(),
163 company_code: entry.company_code().to_string(),
164 entry_id: Some(entry.document_number().clone()),
165 error_type: ValidationErrorType::UnbalancedEntry,
166 message: format!(
167 "Entry {} is unbalanced: debits={}, credits={}",
168 entry.document_number(),
169 entry.total_debit(),
170 entry.total_credit()
171 ),
172 details: {
173 let mut d = HashMap::new();
174 d.insert("total_debit".to_string(), entry.total_debit());
175 d.insert("total_credit".to_string(), entry.total_credit());
176 d
177 },
178 };
179
180 if self.config.fail_on_validation_error {
181 return Err(error);
182 }
183 self.validation_errors.push(error);
184 }
185
186 let company_code = entry.company_code().to_string();
188 let document_number = entry.document_number().clone();
189 let posting_date = entry.posting_date();
190 let track_history = self.config.track_history;
191
192 let line_data: Vec<_> = entry
194 .lines
195 .iter()
196 .map(|line| {
197 let account_type = self.determine_account_type(&line.account_code);
198 (line.clone(), account_type)
199 })
200 .collect();
201
202 let company_balances = self.balances.entry(company_code.clone()).or_default();
204
205 let mut history_entries = Vec::new();
207
208 for (line, account_type) in &line_data {
210 let balance = company_balances
212 .entry(line.account_code.clone())
213 .or_insert_with(|| {
214 AccountBalance::new(
215 company_code.clone(),
216 line.account_code.clone(),
217 *account_type,
218 "USD".to_string(),
219 posting_date.year(),
220 posting_date.month(),
221 )
222 });
223
224 let previous_balance = balance.closing_balance;
225
226 if line.debit_amount > Decimal::ZERO {
228 balance.apply_debit(line.debit_amount);
229 }
230 if line.credit_amount > Decimal::ZERO {
231 balance.apply_credit(line.credit_amount);
232 }
233
234 let new_balance = balance.closing_balance;
235
236 if track_history {
238 let change = line.debit_amount - line.credit_amount;
239 history_entries.push(BalanceHistoryEntry {
240 date: posting_date,
241 entry_id: document_number.clone(),
242 account_code: line.account_code.clone(),
243 previous_balance,
244 change,
245 new_balance,
246 });
247 }
248 }
249
250 if !history_entries.is_empty() {
252 let hist = self.history.entry(company_code.clone()).or_default();
253 hist.extend(history_entries);
254 }
255
256 self.stats.entries_processed += 1;
258 self.stats.lines_processed += entry.lines.len() as u64;
259 self.stats.total_debits += entry.total_debit();
260 self.stats.total_credits += entry.total_credit();
261 self.stats.companies_tracked = self.balances.len();
262 self.stats.accounts_tracked = self.balances.values().map(|b| b.len()).sum();
263
264 if self.config.validate_on_each_entry {
266 self.validate_balance_sheet(
267 entry.company_code(),
268 entry.posting_date(),
269 Some(&entry.document_number()),
270 )?;
271 }
272
273 Ok(())
274 }
275
276 pub fn apply_entries(&mut self, entries: &[JournalEntry]) -> Vec<ValidationError> {
278 let mut errors = Vec::new();
279
280 for entry in entries {
281 if let Err(error) = self.apply_entry(entry) {
282 errors.push(error);
283 }
284 }
285
286 errors
287 }
288
289 fn apply_line(
291 &mut self,
292 company_balances: &mut HashMap<String, AccountBalance>,
293 line: &JournalEntryLine,
294 entry_id: &str,
295 date: NaiveDate,
296 company_code: &str,
297 ) {
298 let account_type = self.determine_account_type(&line.account_code);
299
300 let balance = company_balances
302 .entry(line.account_code.clone())
303 .or_insert_with(|| {
304 AccountBalance::new(
305 company_code.to_string(),
306 line.account_code.clone(),
307 account_type,
308 "USD".to_string(), date.year(),
310 date.month(),
311 )
312 });
313
314 let previous_balance = balance.closing_balance;
315
316 if line.debit_amount > Decimal::ZERO {
318 balance.apply_debit(line.debit_amount);
319 }
320 if line.credit_amount > Decimal::ZERO {
321 balance.apply_credit(line.credit_amount);
322 }
323
324 let new_balance = balance.closing_balance;
325
326 if self.config.track_history {
328 let history_entries = self.history.entry(company_code.to_string()).or_default();
329 history_entries.push(BalanceHistoryEntry {
330 date,
331 entry_id: entry_id.to_string(),
332 account_code: line.account_code.clone(),
333 previous_balance,
334 change: new_balance - previous_balance,
335 new_balance,
336 });
337 }
338 }
339
340 fn determine_account_type(&self, account_code: &str) -> AccountType {
342 for (registered_code, account_type) in &self.account_types {
344 if account_code.starts_with(registered_code) {
345 return *account_type;
346 }
347 }
348
349 match account_code.chars().next() {
351 Some('1') => AccountType::Asset,
352 Some('2') => AccountType::Liability,
353 Some('3') => AccountType::Equity,
354 Some('4') => AccountType::Revenue,
355 Some('5') | Some('6') | Some('7') | Some('8') => AccountType::Expense,
356 _ => AccountType::Asset, }
358 }
359
360 pub fn validate_balance_sheet(
362 &mut self,
363 company_code: &str,
364 date: NaiveDate,
365 entry_id: Option<&str>,
366 ) -> Result<(), ValidationError> {
367 let Some(company_balances) = self.balances.get(company_code) else {
368 return Ok(()); };
370
371 let mut total_assets = Decimal::ZERO;
372 let mut total_liabilities = Decimal::ZERO;
373 let mut total_equity = Decimal::ZERO;
374 let mut total_revenue = Decimal::ZERO;
375 let mut total_expenses = Decimal::ZERO;
376
377 for (account_code, balance) in company_balances {
378 let account_type = self.determine_account_type(account_code);
379 match account_type {
380 AccountType::Asset => total_assets += balance.closing_balance,
381 AccountType::ContraAsset => total_assets -= balance.closing_balance.abs(),
382 AccountType::Liability => total_liabilities += balance.closing_balance.abs(),
383 AccountType::ContraLiability => total_liabilities -= balance.closing_balance.abs(),
384 AccountType::Equity => total_equity += balance.closing_balance.abs(),
385 AccountType::ContraEquity => total_equity -= balance.closing_balance.abs(),
386 AccountType::Revenue => total_revenue += balance.closing_balance.abs(),
387 AccountType::Expense => total_expenses += balance.closing_balance.abs(),
388 }
389 }
390
391 let net_income = total_revenue - total_expenses;
393
394 let left_side = total_assets;
396 let right_side = total_liabilities + total_equity + net_income;
397 let difference = (left_side - right_side).abs();
398
399 if difference > self.config.balance_tolerance {
400 let error = ValidationError {
401 date,
402 company_code: company_code.to_string(),
403 entry_id: entry_id.map(String::from),
404 error_type: ValidationErrorType::BalanceSheetImbalance,
405 message: format!(
406 "Balance sheet imbalance: Assets ({}) != L + E + NI ({}), diff = {}",
407 left_side, right_side, difference
408 ),
409 details: {
410 let mut d = HashMap::new();
411 d.insert("total_assets".to_string(), total_assets);
412 d.insert("total_liabilities".to_string(), total_liabilities);
413 d.insert("total_equity".to_string(), total_equity);
414 d.insert("net_income".to_string(), net_income);
415 d.insert("difference".to_string(), difference);
416 d
417 },
418 };
419
420 self.stats.validation_errors += 1;
421
422 if self.config.fail_on_validation_error {
423 return Err(error);
424 }
425 self.validation_errors.push(error);
426 }
427
428 Ok(())
429 }
430
431 pub fn get_snapshot(
433 &self,
434 company_code: &str,
435 as_of_date: NaiveDate,
436 ) -> Option<BalanceSnapshot> {
437 use chrono::Datelike;
438 self.balances.get(company_code).map(|balances| {
439 let mut snapshot = BalanceSnapshot::new(
440 format!("SNAP-{}-{}", company_code, as_of_date),
441 company_code.to_string(),
442 as_of_date,
443 as_of_date.year(),
444 as_of_date.month(),
445 "USD".to_string(),
446 );
447 for (account, balance) in balances {
448 snapshot.balances.insert(account.clone(), balance.clone());
449 }
450 snapshot.recalculate_totals();
451 snapshot
452 })
453 }
454
455 pub fn get_all_snapshots(&self, as_of_date: NaiveDate) -> Vec<BalanceSnapshot> {
457 use chrono::Datelike;
458 self.balances
459 .iter()
460 .map(|(company_code, balances)| {
461 let mut snapshot = BalanceSnapshot::new(
462 format!("SNAP-{}-{}", company_code, as_of_date),
463 company_code.clone(),
464 as_of_date,
465 as_of_date.year(),
466 as_of_date.month(),
467 "USD".to_string(),
468 );
469 for (account, balance) in balances {
470 snapshot.balances.insert(account.clone(), balance.clone());
471 }
472 snapshot.recalculate_totals();
473 snapshot
474 })
475 .collect()
476 }
477
478 pub fn get_balance_changes(
480 &self,
481 company_code: &str,
482 from_date: NaiveDate,
483 to_date: NaiveDate,
484 ) -> Vec<AccountPeriodActivity> {
485 let Some(history) = self.history.get(company_code) else {
486 return Vec::new();
487 };
488
489 let mut changes_by_account: HashMap<String, AccountPeriodActivity> = HashMap::new();
490
491 for entry in history
492 .iter()
493 .filter(|e| e.date >= from_date && e.date <= to_date)
494 {
495 let change = changes_by_account
496 .entry(entry.account_code.clone())
497 .or_insert_with(|| AccountPeriodActivity {
498 account_code: entry.account_code.clone(),
499 period_start: from_date,
500 period_end: to_date,
501 opening_balance: Decimal::ZERO,
502 closing_balance: Decimal::ZERO,
503 total_debits: Decimal::ZERO,
504 total_credits: Decimal::ZERO,
505 net_change: Decimal::ZERO,
506 transaction_count: 0,
507 });
508
509 if entry.change > Decimal::ZERO {
510 change.total_debits += entry.change;
511 } else {
512 change.total_credits += entry.change.abs();
513 }
514 change.net_change += entry.change;
515 change.transaction_count += 1;
516 }
517
518 if let Some(company_balances) = self.balances.get(company_code) {
520 for change in changes_by_account.values_mut() {
521 if let Some(balance) = company_balances.get(&change.account_code) {
522 change.closing_balance = balance.closing_balance;
523 change.opening_balance = change.closing_balance - change.net_change;
524 }
525 }
526 }
527
528 changes_by_account.into_values().collect()
529 }
530
531 pub fn get_account_balance(
533 &self,
534 company_code: &str,
535 account_code: &str,
536 ) -> Option<&AccountBalance> {
537 self.balances
538 .get(company_code)
539 .and_then(|b| b.get(account_code))
540 }
541
542 pub fn get_validation_errors(&self) -> &[ValidationError] {
544 &self.validation_errors
545 }
546
547 pub fn clear_validation_errors(&mut self) {
549 self.validation_errors.clear();
550 self.stats.validation_errors = 0;
551 }
552
553 pub fn get_statistics(&self) -> &TrackerStatistics {
555 &self.stats
556 }
557
558 pub fn roll_forward(&mut self, _new_period_start: NaiveDate) {
560 for company_balances in self.balances.values_mut() {
561 for balance in company_balances.values_mut() {
562 balance.roll_forward();
563 }
564 }
565 }
566
567 pub fn export_balances(&self, company_code: &str) -> Vec<(String, Decimal)> {
569 self.balances
570 .get(company_code)
571 .map(|balances| {
572 balances
573 .iter()
574 .map(|(code, balance)| (code.clone(), balance.closing_balance))
575 .collect()
576 })
577 .unwrap_or_default()
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use datasynth_core::models::JournalEntry;
585
586 fn create_test_entry(
587 company: &str,
588 account1: &str,
589 account2: &str,
590 amount: Decimal,
591 ) -> JournalEntry {
592 let mut entry = JournalEntry::new_simple(
593 "TEST001".to_string(),
594 company.to_string(),
595 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
596 "Test entry".to_string(),
597 );
598
599 entry.add_line(JournalEntryLine {
600 line_number: 1,
601 gl_account: account1.to_string(),
602 account_code: account1.to_string(),
603 debit_amount: amount,
604 ..Default::default()
605 });
606
607 entry.add_line(JournalEntryLine {
608 line_number: 2,
609 gl_account: account2.to_string(),
610 account_code: account2.to_string(),
611 credit_amount: amount,
612 ..Default::default()
613 });
614
615 entry
616 }
617
618 #[test]
619 fn test_apply_balanced_entry() {
620 let mut tracker = RunningBalanceTracker::with_defaults();
621 tracker.register_account_type("1100", AccountType::Asset);
622 tracker.register_account_type("4000", AccountType::Revenue);
623
624 let entry = create_test_entry("1000", "1100", "4000", dec!(1000));
625 let result = tracker.apply_entry(&entry);
626
627 assert!(result.is_ok());
628 assert_eq!(tracker.stats.entries_processed, 1);
629 assert_eq!(tracker.stats.lines_processed, 2);
630 }
631
632 #[test]
633 fn test_balance_accumulation() {
634 let mut tracker = RunningBalanceTracker::with_defaults();
635 tracker.config.validate_on_each_entry = false;
636
637 let entry1 = create_test_entry("1000", "1100", "4000", dec!(1000));
638 let entry2 = create_test_entry("1000", "1100", "4000", dec!(500));
639
640 tracker.apply_entry(&entry1).unwrap();
641 tracker.apply_entry(&entry2).unwrap();
642
643 let balance = tracker.get_account_balance("1000", "1100").unwrap();
644 assert_eq!(balance.closing_balance, dec!(1500));
645 }
646
647 #[test]
648 fn test_get_snapshot() {
649 let mut tracker = RunningBalanceTracker::with_defaults();
650 tracker.config.validate_on_each_entry = false;
651
652 let entry = create_test_entry("1000", "1100", "2000", dec!(1000));
653 tracker.apply_entry(&entry).unwrap();
654
655 let snapshot = tracker
656 .get_snapshot("1000", NaiveDate::from_ymd_opt(2024, 1, 31).unwrap())
657 .unwrap();
658
659 assert_eq!(snapshot.balances.len(), 2);
660 }
661
662 #[test]
663 fn test_determine_account_type_from_prefix() {
664 let tracker = RunningBalanceTracker::with_defaults();
665
666 assert_eq!(tracker.determine_account_type("1000"), AccountType::Asset);
667 assert_eq!(
668 tracker.determine_account_type("2000"),
669 AccountType::Liability
670 );
671 assert_eq!(tracker.determine_account_type("3000"), AccountType::Equity);
672 assert_eq!(tracker.determine_account_type("4000"), AccountType::Revenue);
673 assert_eq!(tracker.determine_account_type("5000"), AccountType::Expense);
674 }
675}