Skip to main content

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    #[serde(with = "crate::serde_timestamp::naive")]
48    pub created_at: NaiveDateTime,
49    /// Created by.
50    pub created_by: String,
51    /// Approved by (if applicable).
52    pub approved_by: Option<String>,
53    /// Approval date.
54    #[serde(default, with = "crate::serde_timestamp::naive::option")]
55    pub approved_at: Option<NaiveDateTime>,
56    /// Status.
57    pub status: TrialBalanceStatus,
58}
59
60impl TrialBalance {
61    /// Create a new trial balance.
62    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    /// Add a line to the trial balance.
97    pub fn add_line(&mut self, line: TrialBalanceLine) {
98        self.total_debits += line.debit_balance;
99        self.total_credits += line.credit_balance;
100
101        // Update category summary
102        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    /// Add a line from an AccountBalance.
113    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    /// Recalculate totals and balance status.
147    fn recalculate(&mut self) {
148        // Check DR = CR
149        self.out_of_balance = self.total_debits - self.total_credits;
150        self.is_balanced = self.out_of_balance.abs() < dec!(0.01);
151
152        // Check accounting equation: Assets = Liabilities + Equity
153        // For balance sheet accounts only
154        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    /// Validate the accounting equation (Assets = Liabilities + Equity).
163    ///
164    /// Returns true if the equation holds within tolerance, along with
165    /// the calculated totals for each component.
166    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    /// Get lines for a specific category.
177    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    /// Get total for a category.
185    pub fn get_category_total(&self, category: AccountCategory) -> Option<&CategorySummary> {
186        self.category_summary.get(&category)
187    }
188
189    /// Get total assets.
190    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    /// Get total liabilities.
203    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    /// Get total equity.
216    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    /// Get total revenue.
224    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    /// Get total expenses.
232    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    /// Get net income.
250    pub fn net_income(&self) -> Decimal {
251        self.total_revenue() - self.total_expenses()
252    }
253
254    /// Finalize the trial balance.
255    pub fn finalize(&mut self) {
256        if self.is_balanced {
257            self.status = TrialBalanceStatus::Final;
258        }
259    }
260
261    /// Approve the trial balance.
262    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    /// Sort lines by account code.
269    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    /// Sort lines by category then account code.
275    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/// Type of trial balance.
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
286#[serde(rename_all = "snake_case")]
287pub enum TrialBalanceType {
288    /// Unadjusted trial balance (before adjusting entries).
289    Unadjusted,
290    /// Adjusted trial balance (after adjusting entries).
291    #[default]
292    Adjusted,
293    /// Post-closing trial balance (after closing entries).
294    PostClosing,
295    /// Consolidated trial balance.
296    Consolidated,
297}
298
299/// Status of trial balance.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
301#[serde(rename_all = "snake_case")]
302pub enum TrialBalanceStatus {
303    /// Draft - still being prepared.
304    #[default]
305    Draft,
306    /// Final - period closed.
307    Final,
308    /// Approved - reviewed and approved.
309    Approved,
310    /// Archived.
311    Archived,
312}
313
314/// A single line in a trial balance.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct TrialBalanceLine {
317    /// Account code.
318    pub account_code: String,
319    /// Account description.
320    pub account_description: String,
321    /// Account category.
322    pub category: AccountCategory,
323    /// Account type.
324    pub account_type: AccountType,
325    /// Opening balance.
326    pub opening_balance: Decimal,
327    /// Period debits.
328    pub period_debits: Decimal,
329    /// Period credits.
330    pub period_credits: Decimal,
331    /// Closing balance.
332    pub closing_balance: Decimal,
333    /// Debit balance (for trial balance display).
334    pub debit_balance: Decimal,
335    /// Credit balance (for trial balance display).
336    pub credit_balance: Decimal,
337    /// Cost center.
338    pub cost_center: Option<String>,
339    /// Profit center.
340    pub profit_center: Option<String>,
341}
342
343impl TrialBalanceLine {
344    /// Get the net balance.
345    pub fn net_balance(&self) -> Decimal {
346        self.debit_balance - self.credit_balance
347    }
348}
349
350/// Account category for grouping.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum AccountCategory {
354    /// Current assets (cash, AR, inventory).
355    CurrentAssets,
356    /// Non-current assets (fixed assets, intangibles).
357    NonCurrentAssets,
358    /// Current liabilities (AP, short-term debt).
359    CurrentLiabilities,
360    /// Non-current liabilities (long-term debt).
361    NonCurrentLiabilities,
362    /// Equity (capital, retained earnings).
363    Equity,
364    /// Revenue.
365    Revenue,
366    /// Cost of goods sold.
367    CostOfGoodsSold,
368    /// Operating expenses.
369    OperatingExpenses,
370    /// Other income.
371    OtherIncome,
372    /// Other expenses.
373    OtherExpenses,
374}
375
376impl AccountCategory {
377    /// Determine category from account type.
378    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    /// Determine category from account code (US GAAP heuristic).
389    ///
390    /// For framework-aware classification, use
391    /// [`from_account_code_with_framework`](Self::from_account_code_with_framework).
392    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    /// Determine category using framework-aware classification.
414    ///
415    /// `framework` is the framework string (e.g. `"us_gaap"`, `"french_gaap"`,
416    /// `"german_gaap"`, `"ifrs"`). Uses [`FrameworkAccounts`] internally.
417    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    /// Get display name.
423    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    /// Is this a balance sheet category?
439    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    /// Is this an income statement category?
451    pub fn is_income_statement(&self) -> bool {
452        !self.is_balance_sheet()
453    }
454}
455
456/// Summary for an account category.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct CategorySummary {
459    /// Category.
460    pub category: AccountCategory,
461    /// Number of accounts.
462    pub account_count: usize,
463    /// Total debits.
464    pub total_debits: Decimal,
465    /// Total credits.
466    pub total_credits: Decimal,
467}
468
469impl CategorySummary {
470    /// Create a new category summary.
471    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    /// Add a balance to the summary.
481    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    /// Get net balance.
488    pub fn net_balance(&self) -> Decimal {
489        self.total_debits - self.total_credits
490    }
491}
492
493/// Comparative trial balance (multiple periods).
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct ComparativeTrialBalance {
496    /// Company code.
497    pub company_code: String,
498    /// Currency.
499    pub currency: String,
500    /// Periods included.
501    pub periods: Vec<(i32, u32)>, // (fiscal_year, fiscal_period)
502    /// Lines with balances for each period.
503    pub lines: Vec<ComparativeTrialBalanceLine>,
504    /// Created timestamp.
505    #[serde(with = "crate::serde_timestamp::naive")]
506    pub created_at: NaiveDateTime,
507}
508
509/// A line in a comparative trial balance.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct ComparativeTrialBalanceLine {
512    /// Account code.
513    pub account_code: String,
514    /// Account description.
515    pub account_description: String,
516    /// Category.
517    pub category: AccountCategory,
518    /// Balances by period.
519    pub period_balances: HashMap<(i32, u32), Decimal>,
520    /// Period-over-period changes.
521    pub period_changes: HashMap<(i32, u32), Decimal>,
522}
523
524impl ComparativeTrialBalance {
525    /// Create from multiple trial balances.
526    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        // Collect all unique accounts
537        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        // Calculate period-over-period changes
556        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(&current)
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        // Add cash (asset - debit normal)
609        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        // Add AP (liability - credit normal)
625        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        // Add equity
641        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        // Should produce results consistent with the original from_account_code
731        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        // PCG: class 1 = Equity (not CurrentAssets)
748        assert_eq!(
749            AccountCategory::from_account_code_with_framework("101000", "french_gaap"),
750            AccountCategory::Equity
751        );
752        // PCG: class 2 = Asset
753        assert_eq!(
754            AccountCategory::from_account_code_with_framework("210000", "french_gaap"),
755            AccountCategory::CurrentAssets
756        );
757        // PCG: class 6 = OperatingExpenses
758        assert_eq!(
759            AccountCategory::from_account_code_with_framework("603000", "french_gaap"),
760            AccountCategory::OperatingExpenses
761        );
762        // PCG: class 7 = Revenue
763        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        // SKR04: class 0 = Asset
772        assert_eq!(
773            AccountCategory::from_account_code_with_framework("0200", "german_gaap"),
774            AccountCategory::CurrentAssets
775        );
776        // SKR04: class 2 = Equity
777        assert_eq!(
778            AccountCategory::from_account_code_with_framework("2000", "german_gaap"),
779            AccountCategory::Equity
780        );
781        // SKR04: class 3 = Liability
782        assert_eq!(
783            AccountCategory::from_account_code_with_framework("3300", "german_gaap"),
784            AccountCategory::CurrentLiabilities
785        );
786        // SKR04: class 5 = COGS
787        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}