1use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use std::collections::HashMap;
14use tracing::debug;
15
16use datasynth_core::models::balance::{
17 AccountBalance, AccountCategory, AccountType, BalanceSnapshot, CategorySummary,
18 ComparativeTrialBalance, TrialBalance, TrialBalanceLine, TrialBalanceStatus, TrialBalanceType,
19};
20use datasynth_core::models::ChartOfAccounts;
21
22use super::RunningBalanceTracker;
23
24#[derive(Debug, Clone)]
26pub struct TrialBalanceConfig {
27 pub include_zero_balances: bool,
29 pub group_by_category: bool,
31 pub generate_subtotals: bool,
33 pub sort_by_account_code: bool,
35 pub trial_balance_type: TrialBalanceType,
37}
38
39impl Default for TrialBalanceConfig {
40 fn default() -> Self {
41 Self {
42 include_zero_balances: false,
43 group_by_category: true,
44 generate_subtotals: true,
45 sort_by_account_code: true,
46 trial_balance_type: TrialBalanceType::Unadjusted,
47 }
48 }
49}
50
51pub struct TrialBalanceGenerator {
53 config: TrialBalanceConfig,
54 category_mappings: HashMap<String, AccountCategory>,
56 account_descriptions: HashMap<String, String>,
58}
59
60impl TrialBalanceGenerator {
61 pub fn new(config: TrialBalanceConfig) -> Self {
63 Self {
64 config,
65 category_mappings: HashMap::new(),
66 account_descriptions: HashMap::new(),
67 }
68 }
69
70 pub fn with_defaults() -> Self {
72 Self::new(TrialBalanceConfig::default())
73 }
74
75 pub fn register_from_chart(&mut self, chart: &ChartOfAccounts) {
77 for account in &chart.accounts {
78 self.account_descriptions.insert(
79 account.account_code().to_string(),
80 account.description().to_string(),
81 );
82
83 let category = self.determine_category(account.account_code());
85 self.category_mappings
86 .insert(account.account_code().to_string(), category);
87 }
88 }
89
90 pub fn register_category(&mut self, account_code: &str, category: AccountCategory) {
92 self.category_mappings
93 .insert(account_code.to_string(), category);
94 }
95
96 pub fn generate_from_snapshot(
98 &self,
99 snapshot: &BalanceSnapshot,
100 fiscal_year: i32,
101 fiscal_period: u32,
102 ) -> TrialBalance {
103 debug!(
104 company_code = %snapshot.company_code,
105 fiscal_year,
106 fiscal_period,
107 balance_count = snapshot.balances.len(),
108 "Generating trial balance from snapshot"
109 );
110
111 let mut lines = Vec::new();
112 let mut total_debits = Decimal::ZERO;
113 let mut total_credits = Decimal::ZERO;
114
115 for (account_code, balance) in &snapshot.balances {
117 if !self.config.include_zero_balances && balance.closing_balance == Decimal::ZERO {
118 continue;
119 }
120
121 let (debit, credit) = self.split_balance(balance);
122 total_debits += debit;
123 total_credits += credit;
124
125 let category = self.determine_category(account_code);
126 let description = self
127 .account_descriptions
128 .get(account_code)
129 .cloned()
130 .unwrap_or_else(|| format!("Account {}", account_code));
131
132 lines.push(TrialBalanceLine {
133 account_code: account_code.clone(),
134 account_description: description,
135 category,
136 account_type: balance.account_type,
137 debit_balance: debit,
138 credit_balance: credit,
139 opening_balance: balance.opening_balance,
140 period_debits: balance.period_debits,
141 period_credits: balance.period_credits,
142 closing_balance: balance.closing_balance,
143 cost_center: None,
144 profit_center: None,
145 });
146 }
147
148 if self.config.sort_by_account_code {
150 lines.sort_by(|a, b| a.account_code.cmp(&b.account_code));
151 }
152
153 let category_summary = if self.config.group_by_category {
155 self.calculate_category_summary(&lines)
156 } else {
157 HashMap::new()
158 };
159
160 let out_of_balance = total_debits - total_credits;
161
162 let mut tb = TrialBalance {
163 trial_balance_id: format!(
164 "TB-{}-{}-{:02}",
165 snapshot.company_code, fiscal_year, fiscal_period
166 ),
167 company_code: snapshot.company_code.clone(),
168 company_name: None,
169 as_of_date: snapshot.as_of_date,
170 fiscal_year,
171 fiscal_period,
172 currency: snapshot.currency.clone(),
173 balance_type: self.config.trial_balance_type,
174 lines,
175 total_debits,
176 total_credits,
177 is_balanced: out_of_balance.abs() < dec!(0.01),
178 out_of_balance,
179 is_equation_valid: false, equation_difference: Decimal::ZERO, category_summary,
182 created_at: snapshot
183 .as_of_date
184 .and_hms_opt(23, 59, 59)
185 .unwrap_or_default(),
186 created_by: "TrialBalanceGenerator".to_string(),
187 approved_by: None,
188 approved_at: None,
189 status: TrialBalanceStatus::Draft,
190 };
191
192 let (is_valid, _assets, _liabilities, _equity, diff) = tb.validate_accounting_equation();
194 tb.is_equation_valid = is_valid;
195 tb.equation_difference = diff;
196
197 tb
198 }
199
200 pub fn generate_from_tracker(
202 &self,
203 tracker: &RunningBalanceTracker,
204 company_code: &str,
205 as_of_date: NaiveDate,
206 fiscal_year: i32,
207 fiscal_period: u32,
208 ) -> Option<TrialBalance> {
209 tracker
210 .get_snapshot(company_code, as_of_date)
211 .map(|snapshot| self.generate_from_snapshot(&snapshot, fiscal_year, fiscal_period))
212 }
213
214 pub fn generate_all_from_tracker(
216 &self,
217 tracker: &RunningBalanceTracker,
218 as_of_date: NaiveDate,
219 fiscal_year: i32,
220 fiscal_period: u32,
221 ) -> Vec<TrialBalance> {
222 tracker
223 .get_all_snapshots(as_of_date)
224 .iter()
225 .map(|snapshot| self.generate_from_snapshot(snapshot, fiscal_year, fiscal_period))
226 .collect()
227 }
228
229 pub fn generate_comparative(
231 &self,
232 snapshots: &[(NaiveDate, BalanceSnapshot)],
233 fiscal_year: i32,
234 ) -> ComparativeTrialBalance {
235 use datasynth_core::models::balance::ComparativeTrialBalanceLine;
236
237 let trial_balances: Vec<TrialBalance> = snapshots
239 .iter()
240 .enumerate()
241 .map(|(i, (date, snapshot))| {
242 let mut tb = self.generate_from_snapshot(snapshot, fiscal_year, (i + 1) as u32);
243 tb.as_of_date = *date;
244 tb
245 })
246 .collect();
247
248 let periods: Vec<(i32, u32)> = trial_balances
250 .iter()
251 .map(|tb| (tb.fiscal_year, tb.fiscal_period))
252 .collect();
253
254 let mut lines_map: HashMap<String, ComparativeTrialBalanceLine> = HashMap::new();
256
257 for tb in &trial_balances {
258 for line in &tb.lines {
259 let entry = lines_map
260 .entry(line.account_code.clone())
261 .or_insert_with(|| ComparativeTrialBalanceLine {
262 account_code: line.account_code.clone(),
263 account_description: line.account_description.clone(),
264 category: line.category,
265 period_balances: HashMap::new(),
266 period_changes: HashMap::new(),
267 });
268
269 entry
270 .period_balances
271 .insert((tb.fiscal_year, tb.fiscal_period), line.closing_balance);
272 }
273 }
274
275 for line in lines_map.values_mut() {
277 let mut sorted_periods: Vec<_> = line.period_balances.keys().cloned().collect();
278 sorted_periods.sort();
279
280 for i in 1..sorted_periods.len() {
281 let prev_period = sorted_periods[i - 1];
282 let curr_period = sorted_periods[i];
283
284 if let (Some(&prev_balance), Some(&curr_balance)) = (
285 line.period_balances.get(&prev_period),
286 line.period_balances.get(&curr_period),
287 ) {
288 line.period_changes
289 .insert(curr_period, curr_balance - prev_balance);
290 }
291 }
292 }
293
294 let lines: Vec<ComparativeTrialBalanceLine> = lines_map.into_values().collect();
295
296 let company_code = snapshots
297 .first()
298 .map(|(_, s)| s.company_code.clone())
299 .unwrap_or_default();
300
301 let currency = snapshots
302 .first()
303 .map(|(_, s)| s.currency.clone())
304 .unwrap_or_else(|| "USD".to_string());
305
306 let created_at = snapshots
307 .last()
308 .map(|(date, _)| date.and_hms_opt(23, 59, 59).unwrap_or_default())
309 .unwrap_or_default();
310
311 ComparativeTrialBalance {
312 company_code,
313 currency,
314 periods,
315 lines,
316 created_at,
317 }
318 }
319
320 pub fn generate_consolidated(
322 &self,
323 trial_balances: &[TrialBalance],
324 consolidated_company_code: &str,
325 ) -> TrialBalance {
326 let mut consolidated_balances: HashMap<String, TrialBalanceLine> = HashMap::new();
327
328 for tb in trial_balances {
329 for line in &tb.lines {
330 let entry = consolidated_balances
331 .entry(line.account_code.clone())
332 .or_insert_with(|| TrialBalanceLine {
333 account_code: line.account_code.clone(),
334 account_description: line.account_description.clone(),
335 category: line.category,
336 account_type: line.account_type,
337 debit_balance: Decimal::ZERO,
338 credit_balance: Decimal::ZERO,
339 opening_balance: Decimal::ZERO,
340 period_debits: Decimal::ZERO,
341 period_credits: Decimal::ZERO,
342 closing_balance: Decimal::ZERO,
343 cost_center: None,
344 profit_center: None,
345 });
346
347 entry.debit_balance += line.debit_balance;
348 entry.credit_balance += line.credit_balance;
349 entry.opening_balance += line.opening_balance;
350 entry.period_debits += line.period_debits;
351 entry.period_credits += line.period_credits;
352 entry.closing_balance += line.closing_balance;
353 }
354 }
355
356 let mut lines: Vec<TrialBalanceLine> = consolidated_balances.into_values().collect();
357 if self.config.sort_by_account_code {
358 lines.sort_by(|a, b| a.account_code.cmp(&b.account_code));
359 }
360
361 let total_debits: Decimal = lines.iter().map(|l| l.debit_balance).sum();
362 let total_credits: Decimal = lines.iter().map(|l| l.credit_balance).sum();
363
364 let category_summary = if self.config.group_by_category {
365 self.calculate_category_summary(&lines)
366 } else {
367 HashMap::new()
368 };
369
370 let as_of_date = trial_balances
371 .first()
372 .map(|tb| tb.as_of_date)
373 .unwrap_or_else(|| chrono::Local::now().date_naive());
374
375 let fiscal_year = trial_balances.first().map(|tb| tb.fiscal_year).unwrap_or(0);
376 let fiscal_period = trial_balances
377 .first()
378 .map(|tb| tb.fiscal_period)
379 .unwrap_or(0);
380
381 let currency = trial_balances
382 .first()
383 .map(|tb| tb.currency.clone())
384 .unwrap_or_else(|| "USD".to_string());
385
386 let out_of_balance = total_debits - total_credits;
387
388 let mut tb = TrialBalance {
389 trial_balance_id: format!(
390 "TB-CONS-{}-{}-{:02}",
391 consolidated_company_code, fiscal_year, fiscal_period
392 ),
393 company_code: consolidated_company_code.to_string(),
394 company_name: None,
395 as_of_date,
396 fiscal_year,
397 fiscal_period,
398 currency,
399 balance_type: TrialBalanceType::Consolidated,
400 lines,
401 total_debits,
402 total_credits,
403 is_balanced: out_of_balance.abs() < dec!(0.01),
404 out_of_balance,
405 is_equation_valid: false, equation_difference: Decimal::ZERO, category_summary,
408 created_at: as_of_date.and_hms_opt(23, 59, 59).unwrap_or_default(),
409 created_by: format!(
410 "TrialBalanceGenerator (Consolidated from {} companies)",
411 trial_balances.len()
412 ),
413 approved_by: None,
414 approved_at: None,
415 status: TrialBalanceStatus::Draft,
416 };
417
418 let (is_valid, _assets, _liabilities, _equity, diff) = tb.validate_accounting_equation();
420 tb.is_equation_valid = is_valid;
421 tb.equation_difference = diff;
422
423 tb
424 }
425
426 fn split_balance(&self, balance: &AccountBalance) -> (Decimal, Decimal) {
428 let closing = balance.closing_balance;
429
430 match balance.account_type {
432 AccountType::Asset | AccountType::Expense => {
433 if closing >= Decimal::ZERO {
434 (closing, Decimal::ZERO)
435 } else {
436 (Decimal::ZERO, closing.abs())
437 }
438 }
439 AccountType::ContraAsset | AccountType::ContraLiability | AccountType::ContraEquity => {
440 if closing >= Decimal::ZERO {
442 (Decimal::ZERO, closing)
443 } else {
444 (closing.abs(), Decimal::ZERO)
445 }
446 }
447 AccountType::Liability | AccountType::Equity | AccountType::Revenue => {
448 if closing >= Decimal::ZERO {
449 (Decimal::ZERO, closing)
450 } else {
451 (closing.abs(), Decimal::ZERO)
452 }
453 }
454 }
455 }
456
457 fn determine_category(&self, account_code: &str) -> AccountCategory {
459 if let Some(category) = self.category_mappings.get(account_code) {
461 return *category;
462 }
463
464 let prefix: u32 = account_code
466 .chars()
467 .take(2)
468 .collect::<String>()
469 .parse()
470 .unwrap_or(0);
471
472 match prefix {
473 10..=14 => AccountCategory::CurrentAssets,
474 15..=19 => AccountCategory::NonCurrentAssets,
475 20..=24 => AccountCategory::CurrentLiabilities,
476 25..=29 => AccountCategory::NonCurrentLiabilities,
477 30..=39 => AccountCategory::Equity,
478 40..=44 => AccountCategory::Revenue,
479 50..=54 => AccountCategory::CostOfGoodsSold,
480 55..=69 => AccountCategory::OperatingExpenses,
481 70..=74 => AccountCategory::OtherIncome,
482 75..=99 => AccountCategory::OtherExpenses,
483 _ => AccountCategory::OtherExpenses,
484 }
485 }
486
487 fn calculate_category_summary(
489 &self,
490 lines: &[TrialBalanceLine],
491 ) -> HashMap<AccountCategory, CategorySummary> {
492 let mut summaries: HashMap<AccountCategory, CategorySummary> = HashMap::new();
493
494 for line in lines {
495 let summary = summaries
496 .entry(line.category)
497 .or_insert_with(|| CategorySummary::new(line.category));
498
499 summary.add_balance(line.debit_balance, line.credit_balance);
500 }
501
502 summaries
503 }
504
505 pub fn finalize(&self, mut trial_balance: TrialBalance) -> TrialBalance {
507 trial_balance.status = TrialBalanceStatus::Final;
508 trial_balance
509 }
510
511 pub fn approve(&self, mut trial_balance: TrialBalance, approver: &str) -> TrialBalance {
513 trial_balance.status = TrialBalanceStatus::Approved;
514 trial_balance.approved_by = Some(approver.to_string());
515 trial_balance.approved_at = Some(
516 trial_balance
517 .as_of_date
518 .succ_opt()
519 .unwrap_or(trial_balance.as_of_date)
520 .and_hms_opt(9, 0, 0)
521 .unwrap_or_default(),
522 );
523 trial_balance
524 }
525}
526
527pub struct TrialBalanceBuilder {
529 generator: TrialBalanceGenerator,
530 snapshots: Vec<(String, BalanceSnapshot)>,
531 fiscal_year: i32,
532 fiscal_period: u32,
533}
534
535impl TrialBalanceBuilder {
536 pub fn new(fiscal_year: i32, fiscal_period: u32) -> Self {
538 Self {
539 generator: TrialBalanceGenerator::with_defaults(),
540 snapshots: Vec::new(),
541 fiscal_year,
542 fiscal_period,
543 }
544 }
545
546 pub fn add_snapshot(mut self, company_code: &str, snapshot: BalanceSnapshot) -> Self {
548 self.snapshots.push((company_code.to_string(), snapshot));
549 self
550 }
551
552 pub fn with_config(mut self, config: TrialBalanceConfig) -> Self {
554 self.generator = TrialBalanceGenerator::new(config);
555 self
556 }
557
558 pub fn build(self) -> Vec<TrialBalance> {
560 self.snapshots
561 .iter()
562 .map(|(_, snapshot)| {
563 self.generator.generate_from_snapshot(
564 snapshot,
565 self.fiscal_year,
566 self.fiscal_period,
567 )
568 })
569 .collect()
570 }
571
572 pub fn build_consolidated(self, consolidated_code: &str) -> TrialBalance {
574 let individual = self
575 .snapshots
576 .iter()
577 .map(|(_, snapshot)| {
578 self.generator.generate_from_snapshot(
579 snapshot,
580 self.fiscal_year,
581 self.fiscal_period,
582 )
583 })
584 .collect::<Vec<_>>();
585
586 self.generator
587 .generate_consolidated(&individual, consolidated_code)
588 }
589}
590
591#[cfg(test)]
592#[allow(clippy::unwrap_used)]
593mod tests {
594 use super::*;
595
596 fn create_test_balance(
597 company: &str,
598 account: &str,
599 acct_type: AccountType,
600 opening: Decimal,
601 ) -> AccountBalance {
602 let mut bal = AccountBalance::new(
603 company.to_string(),
604 account.to_string(),
605 acct_type,
606 "USD".to_string(),
607 2024,
608 1,
609 );
610 bal.opening_balance = opening;
611 bal.closing_balance = opening;
612 bal
613 }
614
615 fn create_test_snapshot() -> BalanceSnapshot {
616 let mut snapshot = BalanceSnapshot::new(
617 "SNAP-TEST-2024-01".to_string(),
618 "TEST".to_string(),
619 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
620 2024,
621 1,
622 "USD".to_string(),
623 );
624
625 snapshot.balances.insert(
627 "1100".to_string(),
628 create_test_balance("TEST", "1100", AccountType::Asset, dec!(10000)),
629 );
630
631 snapshot.balances.insert(
633 "2100".to_string(),
634 create_test_balance("TEST", "2100", AccountType::Liability, dec!(5000)),
635 );
636
637 snapshot.balances.insert(
639 "3100".to_string(),
640 create_test_balance("TEST", "3100", AccountType::Equity, dec!(5000)),
641 );
642
643 snapshot.recalculate_totals();
644 snapshot
645 }
646
647 #[test]
648 fn test_generate_trial_balance() {
649 let generator = TrialBalanceGenerator::with_defaults();
650 let snapshot = create_test_snapshot();
651
652 let tb = generator.generate_from_snapshot(&snapshot, 2024, 1);
653
654 assert!(tb.is_balanced);
655 assert_eq!(tb.lines.len(), 3);
656 assert_eq!(tb.total_debits, dec!(10000));
657 assert_eq!(tb.total_credits, dec!(10000));
658 }
659
660 #[test]
661 fn test_category_summaries() {
662 let generator = TrialBalanceGenerator::with_defaults();
663 let snapshot = create_test_snapshot();
664
665 let tb = generator.generate_from_snapshot(&snapshot, 2024, 1);
666
667 assert!(!tb.category_summary.is_empty());
668 }
669
670 #[test]
671 fn test_consolidated_trial_balance() {
672 let generator = TrialBalanceGenerator::with_defaults();
673
674 let snapshot1 = create_test_snapshot();
675 let mut snapshot2 = BalanceSnapshot::new(
676 "SNAP-TEST2-2024-01".to_string(),
677 "TEST2".to_string(),
678 snapshot1.as_of_date,
679 2024,
680 1,
681 "USD".to_string(),
682 );
683
684 for (code, balance) in &snapshot1.balances {
686 let mut new_bal = balance.clone();
687 new_bal.company_code = "TEST2".to_string();
688 new_bal.closing_balance *= dec!(2);
689 new_bal.opening_balance *= dec!(2);
690 snapshot2.balances.insert(code.clone(), new_bal);
691 }
692 snapshot2.recalculate_totals();
693
694 let tb1 = generator.generate_from_snapshot(&snapshot1, 2024, 1);
695 let tb2 = generator.generate_from_snapshot(&snapshot2, 2024, 1);
696
697 let consolidated = generator.generate_consolidated(&[tb1, tb2], "CONSOL");
698
699 assert_eq!(consolidated.company_code, "CONSOL");
700 assert!(consolidated.is_balanced);
701 }
702
703 #[test]
704 fn test_builder_pattern() {
705 let snapshot = create_test_snapshot();
706
707 let trial_balances = TrialBalanceBuilder::new(2024, 1)
708 .add_snapshot("TEST", snapshot)
709 .build();
710
711 assert_eq!(trial_balances.len(), 1);
712 assert!(trial_balances[0].is_balanced);
713 }
714}