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 {
388 let prefix = code.chars().take(2).collect::<String>();
389 match prefix.as_str() {
390 "10" | "11" | "12" | "13" | "14" => Self::CurrentAssets,
391 "15" | "16" | "17" | "18" | "19" => Self::NonCurrentAssets,
392 "20" | "21" | "22" | "23" | "24" => Self::CurrentLiabilities,
393 "25" | "26" | "27" | "28" | "29" => Self::NonCurrentLiabilities,
394 "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37" | "38" | "39" => Self::Equity,
395 "40" | "41" | "42" | "43" | "44" => Self::Revenue,
396 "50" | "51" | "52" => Self::CostOfGoodsSold,
397 "60" | "61" | "62" | "63" | "64" | "65" | "66" | "67" | "68" | "69" => {
398 Self::OperatingExpenses
399 }
400 "70" | "71" | "72" | "73" | "74" => Self::OtherIncome,
401 "80" | "81" | "82" | "83" | "84" | "85" | "86" | "87" | "88" | "89" => {
402 Self::OtherExpenses
403 }
404 _ => Self::OperatingExpenses,
405 }
406 }
407
408 pub fn display_name(&self) -> &'static str {
410 match self {
411 Self::CurrentAssets => "Current Assets",
412 Self::NonCurrentAssets => "Non-Current Assets",
413 Self::CurrentLiabilities => "Current Liabilities",
414 Self::NonCurrentLiabilities => "Non-Current Liabilities",
415 Self::Equity => "Equity",
416 Self::Revenue => "Revenue",
417 Self::CostOfGoodsSold => "Cost of Goods Sold",
418 Self::OperatingExpenses => "Operating Expenses",
419 Self::OtherIncome => "Other Income",
420 Self::OtherExpenses => "Other Expenses",
421 }
422 }
423
424 pub fn is_balance_sheet(&self) -> bool {
426 matches!(
427 self,
428 Self::CurrentAssets
429 | Self::NonCurrentAssets
430 | Self::CurrentLiabilities
431 | Self::NonCurrentLiabilities
432 | Self::Equity
433 )
434 }
435
436 pub fn is_income_statement(&self) -> bool {
438 !self.is_balance_sheet()
439 }
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct CategorySummary {
445 pub category: AccountCategory,
447 pub account_count: usize,
449 pub total_debits: Decimal,
451 pub total_credits: Decimal,
453}
454
455impl CategorySummary {
456 pub fn new(category: AccountCategory) -> Self {
458 Self {
459 category,
460 account_count: 0,
461 total_debits: Decimal::ZERO,
462 total_credits: Decimal::ZERO,
463 }
464 }
465
466 pub fn add_balance(&mut self, debit: Decimal, credit: Decimal) {
468 self.account_count += 1;
469 self.total_debits += debit;
470 self.total_credits += credit;
471 }
472
473 pub fn net_balance(&self) -> Decimal {
475 self.total_debits - self.total_credits
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ComparativeTrialBalance {
482 pub company_code: String,
484 pub currency: String,
486 pub periods: Vec<(i32, u32)>, pub lines: Vec<ComparativeTrialBalanceLine>,
490 pub created_at: NaiveDateTime,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ComparativeTrialBalanceLine {
497 pub account_code: String,
499 pub account_description: String,
501 pub category: AccountCategory,
503 pub period_balances: HashMap<(i32, u32), Decimal>,
505 pub period_changes: HashMap<(i32, u32), Decimal>,
507}
508
509impl ComparativeTrialBalance {
510 pub fn from_trial_balances(trial_balances: Vec<&TrialBalance>) -> Self {
512 let first = trial_balances
513 .first()
514 .expect("At least one trial balance required");
515
516 let periods: Vec<(i32, u32)> = trial_balances
517 .iter()
518 .map(|tb| (tb.fiscal_year, tb.fiscal_period))
519 .collect();
520
521 let mut account_map: HashMap<String, ComparativeTrialBalanceLine> = HashMap::new();
523
524 for tb in &trial_balances {
525 let period = (tb.fiscal_year, tb.fiscal_period);
526 for line in &tb.lines {
527 let entry = account_map
528 .entry(line.account_code.clone())
529 .or_insert_with(|| ComparativeTrialBalanceLine {
530 account_code: line.account_code.clone(),
531 account_description: line.account_description.clone(),
532 category: line.category,
533 period_balances: HashMap::new(),
534 period_changes: HashMap::new(),
535 });
536 entry.period_balances.insert(period, line.closing_balance);
537 }
538 }
539
540 let sorted_periods: Vec<(i32, u32)> = {
542 let mut p = periods.clone();
543 p.sort();
544 p
545 };
546
547 for line in account_map.values_mut() {
548 for i in 1..sorted_periods.len() {
549 let prior = sorted_periods[i - 1];
550 let current = sorted_periods[i];
551 let prior_balance = line
552 .period_balances
553 .get(&prior)
554 .copied()
555 .unwrap_or(Decimal::ZERO);
556 let current_balance = line
557 .period_balances
558 .get(¤t)
559 .copied()
560 .unwrap_or(Decimal::ZERO);
561 line.period_changes
562 .insert(current, current_balance - prior_balance);
563 }
564 }
565
566 Self {
567 company_code: first.company_code.clone(),
568 currency: first.currency.clone(),
569 periods,
570 lines: account_map.into_values().collect(),
571 created_at: chrono::Utc::now().naive_utc(),
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn test_trial_balance_creation() {
582 let mut tb = TrialBalance::new(
583 "TB202206".to_string(),
584 "1000".to_string(),
585 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
586 2022,
587 6,
588 "USD".to_string(),
589 TrialBalanceType::Adjusted,
590 );
591
592 tb.add_line(TrialBalanceLine {
594 account_code: "1100".to_string(),
595 account_description: "Cash".to_string(),
596 category: AccountCategory::CurrentAssets,
597 account_type: AccountType::Asset,
598 opening_balance: dec!(10000),
599 period_debits: dec!(50000),
600 period_credits: dec!(30000),
601 closing_balance: dec!(30000),
602 debit_balance: dec!(30000),
603 credit_balance: Decimal::ZERO,
604 cost_center: None,
605 profit_center: None,
606 });
607
608 tb.add_line(TrialBalanceLine {
610 account_code: "2100".to_string(),
611 account_description: "Accounts Payable".to_string(),
612 category: AccountCategory::CurrentLiabilities,
613 account_type: AccountType::Liability,
614 opening_balance: dec!(5000),
615 period_debits: dec!(10000),
616 period_credits: dec!(25000),
617 closing_balance: dec!(20000),
618 debit_balance: Decimal::ZERO,
619 credit_balance: dec!(20000),
620 cost_center: None,
621 profit_center: None,
622 });
623
624 tb.add_line(TrialBalanceLine {
626 account_code: "3100".to_string(),
627 account_description: "Common Stock".to_string(),
628 category: AccountCategory::Equity,
629 account_type: AccountType::Equity,
630 opening_balance: dec!(10000),
631 period_debits: Decimal::ZERO,
632 period_credits: Decimal::ZERO,
633 closing_balance: dec!(10000),
634 debit_balance: Decimal::ZERO,
635 credit_balance: dec!(10000),
636 cost_center: None,
637 profit_center: None,
638 });
639
640 assert_eq!(tb.total_debits, dec!(30000));
641 assert_eq!(tb.total_credits, dec!(30000));
642 assert!(tb.is_balanced);
643 }
644
645 #[test]
646 fn test_trial_balance_from_account_balance() {
647 let mut tb = TrialBalance::new(
648 "TB202206".to_string(),
649 "1000".to_string(),
650 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
651 2022,
652 6,
653 "USD".to_string(),
654 TrialBalanceType::Adjusted,
655 );
656
657 let mut cash = AccountBalance::new(
658 "1000".to_string(),
659 "1100".to_string(),
660 AccountType::Asset,
661 "USD".to_string(),
662 2022,
663 6,
664 );
665 cash.account_description = Some("Cash".to_string());
666 cash.set_opening_balance(dec!(10000));
667 cash.apply_debit(dec!(5000));
668
669 tb.add_from_account_balance(&cash);
670
671 assert_eq!(tb.lines.len(), 1);
672 assert_eq!(tb.lines[0].debit_balance, dec!(15000));
673 assert_eq!(tb.lines[0].credit_balance, Decimal::ZERO);
674 }
675
676 #[test]
677 fn test_account_category_from_code() {
678 assert_eq!(
679 AccountCategory::from_account_code("1100"),
680 AccountCategory::CurrentAssets
681 );
682 assert_eq!(
683 AccountCategory::from_account_code("1500"),
684 AccountCategory::NonCurrentAssets
685 );
686 assert_eq!(
687 AccountCategory::from_account_code("2100"),
688 AccountCategory::CurrentLiabilities
689 );
690 assert_eq!(
691 AccountCategory::from_account_code("2700"),
692 AccountCategory::NonCurrentLiabilities
693 );
694 assert_eq!(
695 AccountCategory::from_account_code("3100"),
696 AccountCategory::Equity
697 );
698 assert_eq!(
699 AccountCategory::from_account_code("4100"),
700 AccountCategory::Revenue
701 );
702 assert_eq!(
703 AccountCategory::from_account_code("5100"),
704 AccountCategory::CostOfGoodsSold
705 );
706 assert_eq!(
707 AccountCategory::from_account_code("6100"),
708 AccountCategory::OperatingExpenses
709 );
710 }
711
712 #[test]
713 fn test_category_summary() {
714 let mut summary = CategorySummary::new(AccountCategory::CurrentAssets);
715
716 summary.add_balance(dec!(10000), Decimal::ZERO);
717 summary.add_balance(dec!(5000), Decimal::ZERO);
718
719 assert_eq!(summary.account_count, 2);
720 assert_eq!(summary.total_debits, dec!(15000));
721 assert_eq!(summary.net_balance(), dec!(15000));
722 }
723}