datasynth_core/models/balance/
trial_balance.rs

1//! Trial balance model and reporting structures.
2
3use 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/// A trial balance report for a company and period.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TrialBalance {
14    /// Trial balance identifier.
15    pub trial_balance_id: String,
16    /// Company code.
17    pub company_code: String,
18    /// Company name.
19    pub company_name: Option<String>,
20    /// As-of date.
21    pub as_of_date: NaiveDate,
22    /// Fiscal year.
23    pub fiscal_year: i32,
24    /// Fiscal period.
25    pub fiscal_period: u32,
26    /// Currency.
27    pub currency: String,
28    /// Trial balance type.
29    pub balance_type: TrialBalanceType,
30    /// Individual account lines.
31    pub lines: Vec<TrialBalanceLine>,
32    /// Total debits.
33    pub total_debits: Decimal,
34    /// Total credits.
35    pub total_credits: Decimal,
36    /// Is the trial balance balanced (debits = credits)?
37    pub is_balanced: bool,
38    /// Out of balance amount.
39    pub out_of_balance: Decimal,
40    /// Is the accounting equation valid (Assets = Liabilities + Equity)?
41    pub is_equation_valid: bool,
42    /// Difference in accounting equation (Assets - (Liabilities + Equity)).
43    pub equation_difference: Decimal,
44    /// Summary by account category.
45    pub category_summary: HashMap<AccountCategory, CategorySummary>,
46    /// Created timestamp.
47    pub created_at: NaiveDateTime,
48    /// Created by.
49    pub created_by: String,
50    /// Approved by (if applicable).
51    pub approved_by: Option<String>,
52    /// Approval date.
53    pub approved_at: Option<NaiveDateTime>,
54    /// Status.
55    pub status: TrialBalanceStatus,
56}
57
58impl TrialBalance {
59    /// Create a new trial balance.
60    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    /// Add a line to the trial balance.
95    pub fn add_line(&mut self, line: TrialBalanceLine) {
96        self.total_debits += line.debit_balance;
97        self.total_credits += line.credit_balance;
98
99        // Update category summary
100        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    /// Add a line from an AccountBalance.
111    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    /// Recalculate totals and balance status.
145    fn recalculate(&mut self) {
146        // Check DR = CR
147        self.out_of_balance = self.total_debits - self.total_credits;
148        self.is_balanced = self.out_of_balance.abs() < dec!(0.01);
149
150        // Check accounting equation: Assets = Liabilities + Equity
151        // For balance sheet accounts only
152        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    /// Validate the accounting equation (Assets = Liabilities + Equity).
161    ///
162    /// Returns true if the equation holds within tolerance, along with
163    /// the calculated totals for each component.
164    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    /// Get lines for a specific category.
175    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    /// Get total for a category.
183    pub fn get_category_total(&self, category: AccountCategory) -> Option<&CategorySummary> {
184        self.category_summary.get(&category)
185    }
186
187    /// Get total assets.
188    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    /// Get total liabilities.
201    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    /// Get total equity.
214    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    /// Get total revenue.
222    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    /// Get total expenses.
230    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    /// Get net income.
248    pub fn net_income(&self) -> Decimal {
249        self.total_revenue() - self.total_expenses()
250    }
251
252    /// Finalize the trial balance.
253    pub fn finalize(&mut self) {
254        if self.is_balanced {
255            self.status = TrialBalanceStatus::Final;
256        }
257    }
258
259    /// Approve the trial balance.
260    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    /// Sort lines by account code.
267    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    /// Sort lines by category then account code.
273    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/// Type of trial balance.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
284#[serde(rename_all = "snake_case")]
285pub enum TrialBalanceType {
286    /// Unadjusted trial balance (before adjusting entries).
287    Unadjusted,
288    /// Adjusted trial balance (after adjusting entries).
289    #[default]
290    Adjusted,
291    /// Post-closing trial balance (after closing entries).
292    PostClosing,
293    /// Consolidated trial balance.
294    Consolidated,
295}
296
297/// Status of trial balance.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
299#[serde(rename_all = "snake_case")]
300pub enum TrialBalanceStatus {
301    /// Draft - still being prepared.
302    #[default]
303    Draft,
304    /// Final - period closed.
305    Final,
306    /// Approved - reviewed and approved.
307    Approved,
308    /// Archived.
309    Archived,
310}
311
312/// A single line in a trial balance.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TrialBalanceLine {
315    /// Account code.
316    pub account_code: String,
317    /// Account description.
318    pub account_description: String,
319    /// Account category.
320    pub category: AccountCategory,
321    /// Account type.
322    pub account_type: AccountType,
323    /// Opening balance.
324    pub opening_balance: Decimal,
325    /// Period debits.
326    pub period_debits: Decimal,
327    /// Period credits.
328    pub period_credits: Decimal,
329    /// Closing balance.
330    pub closing_balance: Decimal,
331    /// Debit balance (for trial balance display).
332    pub debit_balance: Decimal,
333    /// Credit balance (for trial balance display).
334    pub credit_balance: Decimal,
335    /// Cost center.
336    pub cost_center: Option<String>,
337    /// Profit center.
338    pub profit_center: Option<String>,
339}
340
341impl TrialBalanceLine {
342    /// Get the net balance.
343    pub fn net_balance(&self) -> Decimal {
344        self.debit_balance - self.credit_balance
345    }
346}
347
348/// Account category for grouping.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum AccountCategory {
352    /// Current assets (cash, AR, inventory).
353    CurrentAssets,
354    /// Non-current assets (fixed assets, intangibles).
355    NonCurrentAssets,
356    /// Current liabilities (AP, short-term debt).
357    CurrentLiabilities,
358    /// Non-current liabilities (long-term debt).
359    NonCurrentLiabilities,
360    /// Equity (capital, retained earnings).
361    Equity,
362    /// Revenue.
363    Revenue,
364    /// Cost of goods sold.
365    CostOfGoodsSold,
366    /// Operating expenses.
367    OperatingExpenses,
368    /// Other income.
369    OtherIncome,
370    /// Other expenses.
371    OtherExpenses,
372}
373
374impl AccountCategory {
375    /// Determine category from account type.
376    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    /// Determine category from account code.
387    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    /// Get display name.
409    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    /// Is this a balance sheet category?
425    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    /// Is this an income statement category?
437    pub fn is_income_statement(&self) -> bool {
438        !self.is_balance_sheet()
439    }
440}
441
442/// Summary for an account category.
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct CategorySummary {
445    /// Category.
446    pub category: AccountCategory,
447    /// Number of accounts.
448    pub account_count: usize,
449    /// Total debits.
450    pub total_debits: Decimal,
451    /// Total credits.
452    pub total_credits: Decimal,
453}
454
455impl CategorySummary {
456    /// Create a new category summary.
457    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    /// Add a balance to the summary.
467    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    /// Get net balance.
474    pub fn net_balance(&self) -> Decimal {
475        self.total_debits - self.total_credits
476    }
477}
478
479/// Comparative trial balance (multiple periods).
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ComparativeTrialBalance {
482    /// Company code.
483    pub company_code: String,
484    /// Currency.
485    pub currency: String,
486    /// Periods included.
487    pub periods: Vec<(i32, u32)>, // (fiscal_year, fiscal_period)
488    /// Lines with balances for each period.
489    pub lines: Vec<ComparativeTrialBalanceLine>,
490    /// Created timestamp.
491    pub created_at: NaiveDateTime,
492}
493
494/// A line in a comparative trial balance.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ComparativeTrialBalanceLine {
497    /// Account code.
498    pub account_code: String,
499    /// Account description.
500    pub account_description: String,
501    /// Category.
502    pub category: AccountCategory,
503    /// Balances by period.
504    pub period_balances: HashMap<(i32, u32), Decimal>,
505    /// Period-over-period changes.
506    pub period_changes: HashMap<(i32, u32), Decimal>,
507}
508
509impl ComparativeTrialBalance {
510    /// Create from multiple trial balances.
511    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        // Collect all unique accounts
522        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        // Calculate period-over-period changes
541        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(&current)
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        // Add cash (asset - debit normal)
593        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        // Add AP (liability - credit normal)
609        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        // Add equity
625        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}