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