1use chrono::{NaiveDate, NaiveDateTime};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::account_balance::{AccountBalance, AccountType};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TrialBalance {
14 pub trial_balance_id: String,
16 pub company_code: String,
18 pub company_name: Option<String>,
20 pub as_of_date: NaiveDate,
22 pub fiscal_year: i32,
24 pub fiscal_period: u32,
26 pub currency: String,
28 pub balance_type: TrialBalanceType,
30 pub lines: Vec<TrialBalanceLine>,
32 pub total_debits: Decimal,
34 pub total_credits: Decimal,
36 pub is_balanced: bool,
38 pub out_of_balance: Decimal,
40 pub is_equation_valid: bool,
42 pub equation_difference: Decimal,
44 pub category_summary: HashMap<AccountCategory, CategorySummary>,
46 pub created_at: NaiveDateTime,
48 pub created_by: String,
50 pub approved_by: Option<String>,
52 pub approved_at: Option<NaiveDateTime>,
54 pub status: TrialBalanceStatus,
56}
57
58impl TrialBalance {
59 pub fn new(
61 trial_balance_id: String,
62 company_code: String,
63 as_of_date: NaiveDate,
64 fiscal_year: i32,
65 fiscal_period: u32,
66 currency: String,
67 balance_type: TrialBalanceType,
68 ) -> Self {
69 Self {
70 trial_balance_id,
71 company_code,
72 company_name: None,
73 as_of_date,
74 fiscal_year,
75 fiscal_period,
76 currency,
77 balance_type,
78 lines: Vec::new(),
79 total_debits: Decimal::ZERO,
80 total_credits: Decimal::ZERO,
81 is_balanced: true,
82 out_of_balance: Decimal::ZERO,
83 is_equation_valid: true,
84 equation_difference: Decimal::ZERO,
85 category_summary: HashMap::new(),
86 created_at: chrono::Utc::now().naive_utc(),
87 created_by: "SYSTEM".to_string(),
88 approved_by: None,
89 approved_at: None,
90 status: TrialBalanceStatus::Draft,
91 }
92 }
93
94 pub fn add_line(&mut self, line: TrialBalanceLine) {
96 self.total_debits += line.debit_balance;
97 self.total_credits += line.credit_balance;
98
99 let summary = self
101 .category_summary
102 .entry(line.category)
103 .or_insert_with(|| CategorySummary::new(line.category));
104 summary.add_balance(line.debit_balance, line.credit_balance);
105
106 self.lines.push(line);
107 self.recalculate();
108 }
109
110 pub fn add_from_account_balance(&mut self, balance: &AccountBalance) {
112 let category = AccountCategory::from_account_type(balance.account_type);
113
114 let (debit, credit) = if balance.is_debit_normal() {
115 if balance.closing_balance >= Decimal::ZERO {
116 (balance.closing_balance, Decimal::ZERO)
117 } else {
118 (Decimal::ZERO, balance.closing_balance.abs())
119 }
120 } else if balance.closing_balance >= Decimal::ZERO {
121 (Decimal::ZERO, balance.closing_balance)
122 } else {
123 (balance.closing_balance.abs(), Decimal::ZERO)
124 };
125
126 let line = TrialBalanceLine {
127 account_code: balance.account_code.clone(),
128 account_description: balance.account_description.clone().unwrap_or_default(),
129 category,
130 account_type: balance.account_type,
131 opening_balance: balance.opening_balance,
132 period_debits: balance.period_debits,
133 period_credits: balance.period_credits,
134 closing_balance: balance.closing_balance,
135 debit_balance: debit,
136 credit_balance: credit,
137 cost_center: balance.cost_center.clone(),
138 profit_center: balance.profit_center.clone(),
139 };
140
141 self.add_line(line);
142 }
143
144 fn recalculate(&mut self) {
146 self.out_of_balance = self.total_debits - self.total_credits;
148 self.is_balanced = self.out_of_balance.abs() < dec!(0.01);
149
150 let assets = self.total_assets();
153 let liabilities = self.total_liabilities();
154 let equity = self.total_equity();
155
156 self.equation_difference = assets - (liabilities + equity);
157 self.is_equation_valid = self.equation_difference.abs() < dec!(0.01);
158 }
159
160 pub fn validate_accounting_equation(&self) -> (bool, Decimal, Decimal, Decimal, Decimal) {
165 let assets = self.total_assets();
166 let liabilities = self.total_liabilities();
167 let equity = self.total_equity();
168 let difference = assets - (liabilities + equity);
169 let valid = difference.abs() < dec!(0.01);
170
171 (valid, assets, liabilities, equity, difference)
172 }
173
174 pub fn get_lines_by_category(&self, category: AccountCategory) -> Vec<&TrialBalanceLine> {
176 self.lines
177 .iter()
178 .filter(|l| l.category == category)
179 .collect()
180 }
181
182 pub fn get_category_total(&self, category: AccountCategory) -> Option<&CategorySummary> {
184 self.category_summary.get(&category)
185 }
186
187 pub fn total_assets(&self) -> Decimal {
189 self.category_summary
190 .get(&AccountCategory::CurrentAssets)
191 .map(|s| s.net_balance())
192 .unwrap_or(Decimal::ZERO)
193 + self
194 .category_summary
195 .get(&AccountCategory::NonCurrentAssets)
196 .map(|s| s.net_balance())
197 .unwrap_or(Decimal::ZERO)
198 }
199
200 pub fn total_liabilities(&self) -> Decimal {
202 self.category_summary
203 .get(&AccountCategory::CurrentLiabilities)
204 .map(|s| s.net_balance())
205 .unwrap_or(Decimal::ZERO)
206 + self
207 .category_summary
208 .get(&AccountCategory::NonCurrentLiabilities)
209 .map(|s| s.net_balance())
210 .unwrap_or(Decimal::ZERO)
211 }
212
213 pub fn total_equity(&self) -> Decimal {
215 self.category_summary
216 .get(&AccountCategory::Equity)
217 .map(|s| s.net_balance())
218 .unwrap_or(Decimal::ZERO)
219 }
220
221 pub fn total_revenue(&self) -> Decimal {
223 self.category_summary
224 .get(&AccountCategory::Revenue)
225 .map(|s| s.net_balance())
226 .unwrap_or(Decimal::ZERO)
227 }
228
229 pub fn total_expenses(&self) -> Decimal {
231 self.category_summary
232 .get(&AccountCategory::CostOfGoodsSold)
233 .map(|s| s.net_balance())
234 .unwrap_or(Decimal::ZERO)
235 + self
236 .category_summary
237 .get(&AccountCategory::OperatingExpenses)
238 .map(|s| s.net_balance())
239 .unwrap_or(Decimal::ZERO)
240 + self
241 .category_summary
242 .get(&AccountCategory::OtherExpenses)
243 .map(|s| s.net_balance())
244 .unwrap_or(Decimal::ZERO)
245 }
246
247 pub fn net_income(&self) -> Decimal {
249 self.total_revenue() - self.total_expenses()
250 }
251
252 pub fn finalize(&mut self) {
254 if self.is_balanced {
255 self.status = TrialBalanceStatus::Final;
256 }
257 }
258
259 pub fn approve(&mut self, approved_by: String) {
261 self.approved_by = Some(approved_by);
262 self.approved_at = Some(chrono::Utc::now().naive_utc());
263 self.status = TrialBalanceStatus::Approved;
264 }
265
266 pub fn sort_by_account(&mut self) {
268 self.lines
269 .sort_by(|a, b| a.account_code.cmp(&b.account_code));
270 }
271
272 pub fn sort_by_category(&mut self) {
274 self.lines
275 .sort_by(|a, b| match a.category.cmp(&b.category) {
276 std::cmp::Ordering::Equal => a.account_code.cmp(&b.account_code),
277 other => other,
278 });
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
284#[serde(rename_all = "snake_case")]
285pub enum TrialBalanceType {
286 Unadjusted,
288 #[default]
290 Adjusted,
291 PostClosing,
293 Consolidated,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
299#[serde(rename_all = "snake_case")]
300pub enum TrialBalanceStatus {
301 #[default]
303 Draft,
304 Final,
306 Approved,
308 Archived,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TrialBalanceLine {
315 pub account_code: String,
317 pub account_description: String,
319 pub category: AccountCategory,
321 pub account_type: AccountType,
323 pub opening_balance: Decimal,
325 pub period_debits: Decimal,
327 pub period_credits: Decimal,
329 pub closing_balance: Decimal,
331 pub debit_balance: Decimal,
333 pub credit_balance: Decimal,
335 pub cost_center: Option<String>,
337 pub profit_center: Option<String>,
339}
340
341impl TrialBalanceLine {
342 pub fn net_balance(&self) -> Decimal {
344 self.debit_balance - self.credit_balance
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum AccountCategory {
352 CurrentAssets,
354 NonCurrentAssets,
356 CurrentLiabilities,
358 NonCurrentLiabilities,
360 Equity,
362 Revenue,
364 CostOfGoodsSold,
366 OperatingExpenses,
368 OtherIncome,
370 OtherExpenses,
372}
373
374impl AccountCategory {
375 pub fn from_account_type(account_type: AccountType) -> Self {
377 match account_type {
378 AccountType::Asset | AccountType::ContraAsset => Self::CurrentAssets,
379 AccountType::Liability | AccountType::ContraLiability => Self::CurrentLiabilities,
380 AccountType::Equity | AccountType::ContraEquity => Self::Equity,
381 AccountType::Revenue => Self::Revenue,
382 AccountType::Expense => Self::OperatingExpenses,
383 }
384 }
385
386 pub fn from_account_code(code: &str) -> Self {
391 let prefix = code.chars().take(2).collect::<String>();
392 match prefix.as_str() {
393 "10" | "11" | "12" | "13" | "14" => Self::CurrentAssets,
394 "15" | "16" | "17" | "18" | "19" => Self::NonCurrentAssets,
395 "20" | "21" | "22" | "23" | "24" => Self::CurrentLiabilities,
396 "25" | "26" | "27" | "28" | "29" => Self::NonCurrentLiabilities,
397 "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37" | "38" | "39" => Self::Equity,
398 "40" | "41" | "42" | "43" | "44" => Self::Revenue,
399 "50" | "51" | "52" => Self::CostOfGoodsSold,
400 "60" | "61" | "62" | "63" | "64" | "65" | "66" | "67" | "68" | "69" => {
401 Self::OperatingExpenses
402 }
403 "70" | "71" | "72" | "73" | "74" => Self::OtherIncome,
404 "80" | "81" | "82" | "83" | "84" | "85" | "86" | "87" | "88" | "89" => {
405 Self::OtherExpenses
406 }
407 _ => Self::OperatingExpenses,
408 }
409 }
410
411 pub fn from_account_code_with_framework(code: &str, framework: &str) -> Self {
416 crate::framework_accounts::FrameworkAccounts::for_framework(framework)
417 .classify_trial_balance_category(code)
418 }
419
420 pub fn display_name(&self) -> &'static str {
422 match self {
423 Self::CurrentAssets => "Current Assets",
424 Self::NonCurrentAssets => "Non-Current Assets",
425 Self::CurrentLiabilities => "Current Liabilities",
426 Self::NonCurrentLiabilities => "Non-Current Liabilities",
427 Self::Equity => "Equity",
428 Self::Revenue => "Revenue",
429 Self::CostOfGoodsSold => "Cost of Goods Sold",
430 Self::OperatingExpenses => "Operating Expenses",
431 Self::OtherIncome => "Other Income",
432 Self::OtherExpenses => "Other Expenses",
433 }
434 }
435
436 pub fn is_balance_sheet(&self) -> bool {
438 matches!(
439 self,
440 Self::CurrentAssets
441 | Self::NonCurrentAssets
442 | Self::CurrentLiabilities
443 | Self::NonCurrentLiabilities
444 | Self::Equity
445 )
446 }
447
448 pub fn is_income_statement(&self) -> bool {
450 !self.is_balance_sheet()
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct CategorySummary {
457 pub category: AccountCategory,
459 pub account_count: usize,
461 pub total_debits: Decimal,
463 pub total_credits: Decimal,
465}
466
467impl CategorySummary {
468 pub fn new(category: AccountCategory) -> Self {
470 Self {
471 category,
472 account_count: 0,
473 total_debits: Decimal::ZERO,
474 total_credits: Decimal::ZERO,
475 }
476 }
477
478 pub fn add_balance(&mut self, debit: Decimal, credit: Decimal) {
480 self.account_count += 1;
481 self.total_debits += debit;
482 self.total_credits += credit;
483 }
484
485 pub fn net_balance(&self) -> Decimal {
487 self.total_debits - self.total_credits
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct ComparativeTrialBalance {
494 pub company_code: String,
496 pub currency: String,
498 pub periods: Vec<(i32, u32)>, pub lines: Vec<ComparativeTrialBalanceLine>,
502 pub created_at: NaiveDateTime,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ComparativeTrialBalanceLine {
509 pub account_code: String,
511 pub account_description: String,
513 pub category: AccountCategory,
515 pub period_balances: HashMap<(i32, u32), Decimal>,
517 pub period_changes: HashMap<(i32, u32), Decimal>,
519}
520
521impl ComparativeTrialBalance {
522 pub fn from_trial_balances(trial_balances: Vec<&TrialBalance>) -> Self {
524 let first = trial_balances
525 .first()
526 .expect("At least one trial balance required");
527
528 let periods: Vec<(i32, u32)> = trial_balances
529 .iter()
530 .map(|tb| (tb.fiscal_year, tb.fiscal_period))
531 .collect();
532
533 let mut account_map: HashMap<String, ComparativeTrialBalanceLine> = HashMap::new();
535
536 for tb in &trial_balances {
537 let period = (tb.fiscal_year, tb.fiscal_period);
538 for line in &tb.lines {
539 let entry = account_map
540 .entry(line.account_code.clone())
541 .or_insert_with(|| ComparativeTrialBalanceLine {
542 account_code: line.account_code.clone(),
543 account_description: line.account_description.clone(),
544 category: line.category,
545 period_balances: HashMap::new(),
546 period_changes: HashMap::new(),
547 });
548 entry.period_balances.insert(period, line.closing_balance);
549 }
550 }
551
552 let sorted_periods: Vec<(i32, u32)> = {
554 let mut p = periods.clone();
555 p.sort();
556 p
557 };
558
559 for line in account_map.values_mut() {
560 for i in 1..sorted_periods.len() {
561 let prior = sorted_periods[i - 1];
562 let current = sorted_periods[i];
563 let prior_balance = line
564 .period_balances
565 .get(&prior)
566 .copied()
567 .unwrap_or(Decimal::ZERO);
568 let current_balance = line
569 .period_balances
570 .get(¤t)
571 .copied()
572 .unwrap_or(Decimal::ZERO);
573 line.period_changes
574 .insert(current, current_balance - prior_balance);
575 }
576 }
577
578 Self {
579 company_code: first.company_code.clone(),
580 currency: first.currency.clone(),
581 periods,
582 lines: account_map.into_values().collect(),
583 created_at: chrono::Utc::now().naive_utc(),
584 }
585 }
586}
587
588#[cfg(test)]
589#[allow(clippy::unwrap_used)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn test_trial_balance_creation() {
595 let mut tb = TrialBalance::new(
596 "TB202206".to_string(),
597 "1000".to_string(),
598 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
599 2022,
600 6,
601 "USD".to_string(),
602 TrialBalanceType::Adjusted,
603 );
604
605 tb.add_line(TrialBalanceLine {
607 account_code: "1100".to_string(),
608 account_description: "Cash".to_string(),
609 category: AccountCategory::CurrentAssets,
610 account_type: AccountType::Asset,
611 opening_balance: dec!(10000),
612 period_debits: dec!(50000),
613 period_credits: dec!(30000),
614 closing_balance: dec!(30000),
615 debit_balance: dec!(30000),
616 credit_balance: Decimal::ZERO,
617 cost_center: None,
618 profit_center: None,
619 });
620
621 tb.add_line(TrialBalanceLine {
623 account_code: "2100".to_string(),
624 account_description: "Accounts Payable".to_string(),
625 category: AccountCategory::CurrentLiabilities,
626 account_type: AccountType::Liability,
627 opening_balance: dec!(5000),
628 period_debits: dec!(10000),
629 period_credits: dec!(25000),
630 closing_balance: dec!(20000),
631 debit_balance: Decimal::ZERO,
632 credit_balance: dec!(20000),
633 cost_center: None,
634 profit_center: None,
635 });
636
637 tb.add_line(TrialBalanceLine {
639 account_code: "3100".to_string(),
640 account_description: "Common Stock".to_string(),
641 category: AccountCategory::Equity,
642 account_type: AccountType::Equity,
643 opening_balance: dec!(10000),
644 period_debits: Decimal::ZERO,
645 period_credits: Decimal::ZERO,
646 closing_balance: dec!(10000),
647 debit_balance: Decimal::ZERO,
648 credit_balance: dec!(10000),
649 cost_center: None,
650 profit_center: None,
651 });
652
653 assert_eq!(tb.total_debits, dec!(30000));
654 assert_eq!(tb.total_credits, dec!(30000));
655 assert!(tb.is_balanced);
656 }
657
658 #[test]
659 fn test_trial_balance_from_account_balance() {
660 let mut tb = TrialBalance::new(
661 "TB202206".to_string(),
662 "1000".to_string(),
663 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
664 2022,
665 6,
666 "USD".to_string(),
667 TrialBalanceType::Adjusted,
668 );
669
670 let mut cash = AccountBalance::new(
671 "1000".to_string(),
672 "1100".to_string(),
673 AccountType::Asset,
674 "USD".to_string(),
675 2022,
676 6,
677 );
678 cash.account_description = Some("Cash".to_string());
679 cash.set_opening_balance(dec!(10000));
680 cash.apply_debit(dec!(5000));
681
682 tb.add_from_account_balance(&cash);
683
684 assert_eq!(tb.lines.len(), 1);
685 assert_eq!(tb.lines[0].debit_balance, dec!(15000));
686 assert_eq!(tb.lines[0].credit_balance, Decimal::ZERO);
687 }
688
689 #[test]
690 fn test_account_category_from_code() {
691 assert_eq!(
692 AccountCategory::from_account_code("1100"),
693 AccountCategory::CurrentAssets
694 );
695 assert_eq!(
696 AccountCategory::from_account_code("1500"),
697 AccountCategory::NonCurrentAssets
698 );
699 assert_eq!(
700 AccountCategory::from_account_code("2100"),
701 AccountCategory::CurrentLiabilities
702 );
703 assert_eq!(
704 AccountCategory::from_account_code("2700"),
705 AccountCategory::NonCurrentLiabilities
706 );
707 assert_eq!(
708 AccountCategory::from_account_code("3100"),
709 AccountCategory::Equity
710 );
711 assert_eq!(
712 AccountCategory::from_account_code("4100"),
713 AccountCategory::Revenue
714 );
715 assert_eq!(
716 AccountCategory::from_account_code("5100"),
717 AccountCategory::CostOfGoodsSold
718 );
719 assert_eq!(
720 AccountCategory::from_account_code("6100"),
721 AccountCategory::OperatingExpenses
722 );
723 }
724
725 #[test]
726 fn test_account_category_from_code_with_framework_us_gaap() {
727 assert_eq!(
729 AccountCategory::from_account_code_with_framework("1100", "us_gaap"),
730 AccountCategory::CurrentAssets
731 );
732 assert_eq!(
733 AccountCategory::from_account_code_with_framework("4000", "us_gaap"),
734 AccountCategory::Revenue
735 );
736 assert_eq!(
737 AccountCategory::from_account_code_with_framework("5000", "us_gaap"),
738 AccountCategory::CostOfGoodsSold
739 );
740 }
741
742 #[test]
743 fn test_account_category_from_code_with_framework_french_gaap() {
744 assert_eq!(
746 AccountCategory::from_account_code_with_framework("101000", "french_gaap"),
747 AccountCategory::Equity
748 );
749 assert_eq!(
751 AccountCategory::from_account_code_with_framework("210000", "french_gaap"),
752 AccountCategory::CurrentAssets
753 );
754 assert_eq!(
756 AccountCategory::from_account_code_with_framework("603000", "french_gaap"),
757 AccountCategory::OperatingExpenses
758 );
759 assert_eq!(
761 AccountCategory::from_account_code_with_framework("701000", "french_gaap"),
762 AccountCategory::Revenue
763 );
764 }
765
766 #[test]
767 fn test_account_category_from_code_with_framework_german_gaap() {
768 assert_eq!(
770 AccountCategory::from_account_code_with_framework("0200", "german_gaap"),
771 AccountCategory::CurrentAssets
772 );
773 assert_eq!(
775 AccountCategory::from_account_code_with_framework("2000", "german_gaap"),
776 AccountCategory::Equity
777 );
778 assert_eq!(
780 AccountCategory::from_account_code_with_framework("3300", "german_gaap"),
781 AccountCategory::CurrentLiabilities
782 );
783 assert_eq!(
785 AccountCategory::from_account_code_with_framework("5000", "german_gaap"),
786 AccountCategory::CostOfGoodsSold
787 );
788 }
789
790 #[test]
791 fn test_category_summary() {
792 let mut summary = CategorySummary::new(AccountCategory::CurrentAssets);
793
794 summary.add_balance(dec!(10000), Decimal::ZERO);
795 summary.add_balance(dec!(5000), Decimal::ZERO);
796
797 assert_eq!(summary.account_count, 2);
798 assert_eq!(summary.total_debits, dec!(15000));
799 assert_eq!(summary.net_balance(), dec!(15000));
800 }
801}