Skip to main content

datasynth_core/models/balance/
account_balance.rs

1//! Account balance and balance snapshot models.
2
3use chrono::{NaiveDate, NaiveDateTime};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Account balance for a single GL account.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AccountBalance {
12    /// Company code.
13    pub company_code: String,
14    /// Account code.
15    pub account_code: String,
16    /// Account description.
17    pub account_description: Option<String>,
18    /// Account type (Asset, Liability, Equity, Revenue, Expense).
19    pub account_type: AccountType,
20    /// Currency.
21    pub currency: String,
22    /// Opening balance (beginning of period).
23    pub opening_balance: Decimal,
24    /// Period debits.
25    pub period_debits: Decimal,
26    /// Period credits.
27    pub period_credits: Decimal,
28    /// Closing balance (end of period).
29    pub closing_balance: Decimal,
30    /// Fiscal year.
31    pub fiscal_year: i32,
32    /// Fiscal period.
33    pub fiscal_period: u32,
34    /// Balance in group currency (for consolidation).
35    pub group_currency_balance: Option<Decimal>,
36    /// Exchange rate used.
37    pub exchange_rate: Option<Decimal>,
38    /// Cost center (if applicable).
39    pub cost_center: Option<String>,
40    /// Profit center (if applicable).
41    pub profit_center: Option<String>,
42    /// Last updated timestamp.
43    #[serde(with = "crate::serde_timestamp::naive")]
44    pub last_updated: NaiveDateTime,
45}
46
47impl AccountBalance {
48    /// Create a new account balance.
49    pub fn new(
50        company_code: String,
51        account_code: String,
52        account_type: AccountType,
53        currency: String,
54        fiscal_year: i32,
55        fiscal_period: u32,
56    ) -> Self {
57        Self {
58            company_code,
59            account_code,
60            account_description: None,
61            account_type,
62            currency,
63            opening_balance: Decimal::ZERO,
64            period_debits: Decimal::ZERO,
65            period_credits: Decimal::ZERO,
66            closing_balance: Decimal::ZERO,
67            fiscal_year,
68            fiscal_period,
69            group_currency_balance: None,
70            exchange_rate: None,
71            cost_center: None,
72            profit_center: None,
73            last_updated: chrono::Utc::now().naive_utc(),
74        }
75    }
76
77    /// Apply a debit to this balance.
78    pub fn apply_debit(&mut self, amount: Decimal) {
79        self.period_debits += amount;
80        self.recalculate_closing();
81    }
82
83    /// Apply a credit to this balance.
84    pub fn apply_credit(&mut self, amount: Decimal) {
85        self.period_credits += amount;
86        self.recalculate_closing();
87    }
88
89    /// Recalculate closing balance based on account type.
90    fn recalculate_closing(&mut self) {
91        // Asset and Expense accounts: Debit increases, Credit decreases
92        // Liability, Equity, Revenue accounts: Credit increases, Debit decreases
93        match self.account_type {
94            AccountType::Asset
95            | AccountType::Expense
96            | AccountType::ContraLiability
97            | AccountType::ContraEquity => {
98                self.closing_balance =
99                    self.opening_balance + self.period_debits - self.period_credits;
100            }
101            AccountType::Liability
102            | AccountType::Equity
103            | AccountType::Revenue
104            | AccountType::ContraAsset => {
105                self.closing_balance =
106                    self.opening_balance - self.period_debits + self.period_credits;
107            }
108        }
109        self.last_updated = chrono::Utc::now().naive_utc();
110    }
111
112    /// Set opening balance.
113    pub fn set_opening_balance(&mut self, balance: Decimal) {
114        self.opening_balance = balance;
115        self.recalculate_closing();
116    }
117
118    /// Get the net change for the period.
119    pub fn net_change(&self) -> Decimal {
120        match self.account_type {
121            AccountType::Asset
122            | AccountType::Expense
123            | AccountType::ContraLiability
124            | AccountType::ContraEquity => self.period_debits - self.period_credits,
125            AccountType::Liability
126            | AccountType::Equity
127            | AccountType::Revenue
128            | AccountType::ContraAsset => self.period_credits - self.period_debits,
129        }
130    }
131
132    /// Check if this is a debit-normal account.
133    pub fn is_debit_normal(&self) -> bool {
134        matches!(
135            self.account_type,
136            AccountType::Asset
137                | AccountType::Expense
138                | AccountType::ContraLiability
139                | AccountType::ContraEquity
140        )
141    }
142
143    /// Get the normal balance (positive closing for correct sign).
144    pub fn normal_balance(&self) -> Decimal {
145        if self.is_debit_normal() {
146            self.closing_balance
147        } else {
148            -self.closing_balance
149        }
150    }
151
152    /// Roll forward to next period.
153    pub fn roll_forward(&mut self) {
154        self.opening_balance = self.closing_balance;
155        self.period_debits = Decimal::ZERO;
156        self.period_credits = Decimal::ZERO;
157
158        // Increment period
159        if self.fiscal_period == 12 {
160            self.fiscal_period = 1;
161            self.fiscal_year += 1;
162        } else {
163            self.fiscal_period += 1;
164        }
165
166        self.last_updated = chrono::Utc::now().naive_utc();
167    }
168
169    /// Check if this is a balance sheet account.
170    pub fn is_balance_sheet(&self) -> bool {
171        matches!(
172            self.account_type,
173            AccountType::Asset
174                | AccountType::Liability
175                | AccountType::Equity
176                | AccountType::ContraAsset
177                | AccountType::ContraLiability
178                | AccountType::ContraEquity
179        )
180    }
181
182    /// Check if this is an income statement account.
183    pub fn is_income_statement(&self) -> bool {
184        matches!(
185            self.account_type,
186            AccountType::Revenue | AccountType::Expense
187        )
188    }
189}
190
191/// Account type classification.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
193#[serde(rename_all = "snake_case")]
194pub enum AccountType {
195    /// Assets (debit normal).
196    #[default]
197    Asset,
198    /// Contra-asset (credit normal, e.g., accumulated depreciation).
199    ContraAsset,
200    /// Liabilities (credit normal).
201    Liability,
202    /// Contra-liability (debit normal).
203    ContraLiability,
204    /// Equity (credit normal).
205    Equity,
206    /// Contra-equity (debit normal, e.g., treasury stock).
207    ContraEquity,
208    /// Revenue (credit normal).
209    Revenue,
210    /// Expenses (debit normal).
211    Expense,
212}
213
214impl AccountType {
215    /// Determine account type from account code (simplified, US GAAP heuristic).
216    ///
217    /// For framework-aware classification, use
218    /// [`from_account_code_with_framework`](Self::from_account_code_with_framework).
219    pub fn from_account_code(code: &str) -> Self {
220        let first_char = code.chars().next().unwrap_or('0');
221        match first_char {
222            '1' => Self::Asset,
223            '2' => Self::Liability,
224            '3' => Self::Equity,
225            '4' => Self::Revenue,
226            '5' | '6' | '7' | '8' => Self::Expense,
227            _ => Self::Asset,
228        }
229    }
230
231    /// Determine account type using framework-aware classification.
232    ///
233    /// `framework` is the framework string (e.g. `"us_gaap"`, `"french_gaap"`,
234    /// `"german_gaap"`, `"ifrs"`). Uses [`FrameworkAccounts`] internally.
235    pub fn from_account_code_with_framework(code: &str, framework: &str) -> Self {
236        crate::framework_accounts::FrameworkAccounts::for_framework(framework)
237            .classify_account_type(code)
238    }
239
240    /// Check if contra account based on code pattern.
241    pub fn is_contra_from_code(code: &str) -> bool {
242        // Common patterns for contra accounts
243        code.contains("ACCUM") || code.contains("ALLOW") || code.contains("CONTRA")
244    }
245}
246
247/// A snapshot of all account balances at a point in time.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct BalanceSnapshot {
250    /// Snapshot identifier.
251    pub snapshot_id: String,
252    /// Company code.
253    pub company_code: String,
254    /// Snapshot date.
255    pub as_of_date: NaiveDate,
256    /// Fiscal year.
257    pub fiscal_year: i32,
258    /// Fiscal period.
259    pub fiscal_period: u32,
260    /// Currency.
261    pub currency: String,
262    /// All account balances.
263    pub balances: HashMap<String, AccountBalance>,
264    /// Total assets.
265    pub total_assets: Decimal,
266    /// Total liabilities.
267    pub total_liabilities: Decimal,
268    /// Total equity.
269    pub total_equity: Decimal,
270    /// Total revenue.
271    pub total_revenue: Decimal,
272    /// Total expenses.
273    pub total_expenses: Decimal,
274    /// Net income.
275    pub net_income: Decimal,
276    /// Is the balance sheet balanced (A = L + E)?
277    pub is_balanced: bool,
278    /// Balance sheet difference (should be zero).
279    pub balance_difference: Decimal,
280    /// Created timestamp.
281    #[serde(with = "crate::serde_timestamp::naive")]
282    pub created_at: NaiveDateTime,
283}
284
285impl BalanceSnapshot {
286    /// Create a new balance snapshot.
287    pub fn new(
288        snapshot_id: String,
289        company_code: String,
290        as_of_date: NaiveDate,
291        fiscal_year: i32,
292        fiscal_period: u32,
293        currency: String,
294    ) -> Self {
295        Self {
296            snapshot_id,
297            company_code,
298            as_of_date,
299            fiscal_year,
300            fiscal_period,
301            currency,
302            balances: HashMap::new(),
303            total_assets: Decimal::ZERO,
304            total_liabilities: Decimal::ZERO,
305            total_equity: Decimal::ZERO,
306            total_revenue: Decimal::ZERO,
307            total_expenses: Decimal::ZERO,
308            net_income: Decimal::ZERO,
309            is_balanced: true,
310            balance_difference: Decimal::ZERO,
311            created_at: chrono::Utc::now().naive_utc(),
312        }
313    }
314
315    /// Add an account balance to the snapshot.
316    pub fn add_balance(&mut self, balance: AccountBalance) {
317        let closing = balance.closing_balance;
318
319        match balance.account_type {
320            AccountType::Asset => self.total_assets += closing,
321            AccountType::ContraAsset => self.total_assets -= closing,
322            AccountType::Liability => self.total_liabilities += closing,
323            AccountType::ContraLiability => self.total_liabilities -= closing,
324            AccountType::Equity => self.total_equity += closing,
325            AccountType::ContraEquity => self.total_equity -= closing,
326            AccountType::Revenue => self.total_revenue += closing,
327            AccountType::Expense => self.total_expenses += closing,
328        }
329
330        self.balances.insert(balance.account_code.clone(), balance);
331        self.recalculate_totals();
332    }
333
334    /// Recalculate totals and validate balance sheet equation.
335    pub fn recalculate_totals(&mut self) {
336        self.net_income = self.total_revenue - self.total_expenses;
337
338        // Balance sheet equation: Assets = Liabilities + Equity
339        // For current period, equity includes net income
340        let total_equity_with_income = self.total_equity + self.net_income;
341        self.balance_difference =
342            self.total_assets - self.total_liabilities - total_equity_with_income;
343        self.is_balanced = self.balance_difference.abs() < dec!(0.01);
344    }
345
346    /// Get balance for a specific account.
347    pub fn get_balance(&self, account_code: &str) -> Option<&AccountBalance> {
348        self.balances.get(account_code)
349    }
350
351    /// Get all asset balances.
352    pub fn get_asset_balances(&self) -> Vec<&AccountBalance> {
353        self.balances
354            .values()
355            .filter(|b| {
356                matches!(
357                    b.account_type,
358                    AccountType::Asset | AccountType::ContraAsset
359                )
360            })
361            .collect()
362    }
363
364    /// Get all liability balances.
365    pub fn get_liability_balances(&self) -> Vec<&AccountBalance> {
366        self.balances
367            .values()
368            .filter(|b| {
369                matches!(
370                    b.account_type,
371                    AccountType::Liability | AccountType::ContraLiability
372                )
373            })
374            .collect()
375    }
376
377    /// Get all equity balances.
378    pub fn get_equity_balances(&self) -> Vec<&AccountBalance> {
379        self.balances
380            .values()
381            .filter(|b| {
382                matches!(
383                    b.account_type,
384                    AccountType::Equity | AccountType::ContraEquity
385                )
386            })
387            .collect()
388    }
389
390    /// Get all income statement balances.
391    pub fn get_income_statement_balances(&self) -> Vec<&AccountBalance> {
392        self.balances
393            .values()
394            .filter(|b| b.is_income_statement())
395            .collect()
396    }
397
398    /// Get current ratio (Current Assets / Current Liabilities).
399    pub fn current_ratio(
400        &self,
401        current_asset_accounts: &[&str],
402        current_liability_accounts: &[&str],
403    ) -> Option<Decimal> {
404        let current_assets: Decimal = current_asset_accounts
405            .iter()
406            .filter_map(|code| self.balances.get(*code))
407            .map(|b| b.closing_balance)
408            .sum();
409
410        let current_liabilities: Decimal = current_liability_accounts
411            .iter()
412            .filter_map(|code| self.balances.get(*code))
413            .map(|b| b.closing_balance)
414            .sum();
415
416        if current_liabilities != Decimal::ZERO {
417            Some(current_assets / current_liabilities)
418        } else {
419            None
420        }
421    }
422
423    /// Get debt-to-equity ratio.
424    pub fn debt_to_equity_ratio(&self) -> Option<Decimal> {
425        if self.total_equity != Decimal::ZERO {
426            Some(self.total_liabilities / self.total_equity)
427        } else {
428            None
429        }
430    }
431
432    /// Get gross margin (Revenue - COGS) / Revenue.
433    pub fn gross_margin(&self, cogs_accounts: &[&str]) -> Option<Decimal> {
434        if self.total_revenue == Decimal::ZERO {
435            return None;
436        }
437
438        let cogs: Decimal = cogs_accounts
439            .iter()
440            .filter_map(|code| self.balances.get(*code))
441            .map(|b| b.closing_balance)
442            .sum();
443
444        Some((self.total_revenue - cogs) / self.total_revenue)
445    }
446}
447
448/// Period-over-period balance change analysis.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct BalanceChange {
451    /// Account code.
452    pub account_code: String,
453    /// Account description.
454    pub account_description: Option<String>,
455    /// Prior period balance.
456    pub prior_balance: Decimal,
457    /// Current period balance.
458    pub current_balance: Decimal,
459    /// Absolute change.
460    pub change_amount: Decimal,
461    /// Percentage change.
462    pub change_percent: Option<Decimal>,
463    /// Is this a significant change (above threshold)?
464    pub is_significant: bool,
465}
466
467impl BalanceChange {
468    /// Create a new balance change analysis.
469    pub fn new(
470        account_code: String,
471        account_description: Option<String>,
472        prior_balance: Decimal,
473        current_balance: Decimal,
474        significance_threshold: Decimal,
475    ) -> Self {
476        let change_amount = current_balance - prior_balance;
477        let change_percent = if prior_balance != Decimal::ZERO {
478            Some((change_amount / prior_balance.abs()) * dec!(100))
479        } else {
480            None
481        };
482
483        let is_significant = change_amount.abs() >= significance_threshold
484            || change_percent.is_some_and(|p| p.abs() >= dec!(10));
485
486        Self {
487            account_code,
488            account_description,
489            prior_balance,
490            current_balance,
491            change_amount,
492            change_percent,
493            is_significant,
494        }
495    }
496}
497
498/// Account activity tracking within a period.
499///
500/// Tracks debits, credits, and transaction counts for an account
501/// over a specific period.
502#[derive(Debug, Clone, Serialize, Deserialize, Default)]
503pub struct AccountPeriodActivity {
504    /// Account code.
505    pub account_code: String,
506    /// Period start date.
507    pub period_start: NaiveDate,
508    /// Period end date.
509    pub period_end: NaiveDate,
510    /// Opening balance at period start.
511    pub opening_balance: Decimal,
512    /// Closing balance at period end.
513    pub closing_balance: Decimal,
514    /// Total debit amounts during period.
515    pub total_debits: Decimal,
516    /// Total credit amounts during period.
517    pub total_credits: Decimal,
518    /// Net change (total_debits - total_credits).
519    pub net_change: Decimal,
520    /// Number of transactions during period.
521    pub transaction_count: u32,
522}
523
524impl AccountPeriodActivity {
525    /// Create a new account period activity tracker.
526    pub fn new(account_code: String, period_start: NaiveDate, period_end: NaiveDate) -> Self {
527        Self {
528            account_code,
529            period_start,
530            period_end,
531            opening_balance: Decimal::ZERO,
532            closing_balance: Decimal::ZERO,
533            total_debits: Decimal::ZERO,
534            total_credits: Decimal::ZERO,
535            net_change: Decimal::ZERO,
536            transaction_count: 0,
537        }
538    }
539
540    /// Add a debit transaction.
541    pub fn add_debit(&mut self, amount: Decimal) {
542        self.total_debits += amount;
543        self.net_change += amount;
544        self.transaction_count += 1;
545    }
546
547    /// Add a credit transaction.
548    pub fn add_credit(&mut self, amount: Decimal) {
549        self.total_credits += amount;
550        self.net_change -= amount;
551        self.transaction_count += 1;
552    }
553}
554
555/// Compare two snapshots and identify changes.
556pub fn compare_snapshots(
557    prior: &BalanceSnapshot,
558    current: &BalanceSnapshot,
559    significance_threshold: Decimal,
560) -> Vec<BalanceChange> {
561    let mut changes = Vec::new();
562
563    // Get all unique account codes
564    let mut all_accounts: Vec<&str> = prior
565        .balances
566        .keys()
567        .map(std::string::String::as_str)
568        .collect();
569    for code in current.balances.keys() {
570        if !all_accounts.contains(&code.as_str()) {
571            all_accounts.push(code.as_str());
572        }
573    }
574
575    for account_code in all_accounts {
576        let prior_balance = prior
577            .balances
578            .get(account_code)
579            .map(|b| b.closing_balance)
580            .unwrap_or(Decimal::ZERO);
581
582        let current_balance = current
583            .balances
584            .get(account_code)
585            .map(|b| b.closing_balance)
586            .unwrap_or(Decimal::ZERO);
587
588        let description = current
589            .balances
590            .get(account_code)
591            .and_then(|b| b.account_description.clone())
592            .or_else(|| {
593                prior
594                    .balances
595                    .get(account_code)
596                    .and_then(|b| b.account_description.clone())
597            });
598
599        if prior_balance != current_balance {
600            changes.push(BalanceChange::new(
601                account_code.to_string(),
602                description,
603                prior_balance,
604                current_balance,
605                significance_threshold,
606            ));
607        }
608    }
609
610    changes
611}
612
613#[cfg(test)]
614#[allow(clippy::unwrap_used)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_account_balance_debit_normal() {
620        let mut balance = AccountBalance::new(
621            "1000".to_string(),
622            "1100".to_string(),
623            AccountType::Asset,
624            "USD".to_string(),
625            2022,
626            6,
627        );
628
629        balance.set_opening_balance(dec!(10000));
630        balance.apply_debit(dec!(5000));
631        balance.apply_credit(dec!(2000));
632
633        assert_eq!(balance.closing_balance, dec!(13000)); // 10000 + 5000 - 2000
634        assert_eq!(balance.net_change(), dec!(3000));
635    }
636
637    #[test]
638    fn test_account_balance_credit_normal() {
639        let mut balance = AccountBalance::new(
640            "1000".to_string(),
641            "2100".to_string(),
642            AccountType::Liability,
643            "USD".to_string(),
644            2022,
645            6,
646        );
647
648        balance.set_opening_balance(dec!(10000));
649        balance.apply_credit(dec!(5000));
650        balance.apply_debit(dec!(2000));
651
652        assert_eq!(balance.closing_balance, dec!(13000)); // 10000 - 2000 + 5000
653        assert_eq!(balance.net_change(), dec!(3000));
654    }
655
656    #[test]
657    fn test_balance_snapshot_balanced() {
658        let mut snapshot = BalanceSnapshot::new(
659            "SNAP001".to_string(),
660            "1000".to_string(),
661            NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
662            2022,
663            6,
664            "USD".to_string(),
665        );
666
667        // Add asset
668        let mut cash = AccountBalance::new(
669            "1000".to_string(),
670            "1100".to_string(),
671            AccountType::Asset,
672            "USD".to_string(),
673            2022,
674            6,
675        );
676        cash.closing_balance = dec!(50000);
677        snapshot.add_balance(cash);
678
679        // Add liability
680        let mut ap = AccountBalance::new(
681            "1000".to_string(),
682            "2100".to_string(),
683            AccountType::Liability,
684            "USD".to_string(),
685            2022,
686            6,
687        );
688        ap.closing_balance = dec!(20000);
689        snapshot.add_balance(ap);
690
691        // Add equity
692        let mut equity = AccountBalance::new(
693            "1000".to_string(),
694            "3100".to_string(),
695            AccountType::Equity,
696            "USD".to_string(),
697            2022,
698            6,
699        );
700        equity.closing_balance = dec!(30000);
701        snapshot.add_balance(equity);
702
703        assert!(snapshot.is_balanced);
704        assert_eq!(snapshot.total_assets, dec!(50000));
705        assert_eq!(snapshot.total_liabilities, dec!(20000));
706        assert_eq!(snapshot.total_equity, dec!(30000));
707    }
708
709    #[test]
710    fn test_balance_snapshot_with_income() {
711        let mut snapshot = BalanceSnapshot::new(
712            "SNAP001".to_string(),
713            "1000".to_string(),
714            NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
715            2022,
716            6,
717            "USD".to_string(),
718        );
719
720        // Assets = 60000
721        let mut cash = AccountBalance::new(
722            "1000".to_string(),
723            "1100".to_string(),
724            AccountType::Asset,
725            "USD".to_string(),
726            2022,
727            6,
728        );
729        cash.closing_balance = dec!(60000);
730        snapshot.add_balance(cash);
731
732        // Liabilities = 20000
733        let mut ap = AccountBalance::new(
734            "1000".to_string(),
735            "2100".to_string(),
736            AccountType::Liability,
737            "USD".to_string(),
738            2022,
739            6,
740        );
741        ap.closing_balance = dec!(20000);
742        snapshot.add_balance(ap);
743
744        // Equity = 30000
745        let mut equity = AccountBalance::new(
746            "1000".to_string(),
747            "3100".to_string(),
748            AccountType::Equity,
749            "USD".to_string(),
750            2022,
751            6,
752        );
753        equity.closing_balance = dec!(30000);
754        snapshot.add_balance(equity);
755
756        // Revenue = 50000
757        let mut revenue = AccountBalance::new(
758            "1000".to_string(),
759            "4100".to_string(),
760            AccountType::Revenue,
761            "USD".to_string(),
762            2022,
763            6,
764        );
765        revenue.closing_balance = dec!(50000);
766        snapshot.add_balance(revenue);
767
768        // Expenses = 40000
769        let mut expense = AccountBalance::new(
770            "1000".to_string(),
771            "5100".to_string(),
772            AccountType::Expense,
773            "USD".to_string(),
774            2022,
775            6,
776        );
777        expense.closing_balance = dec!(40000);
778        snapshot.add_balance(expense);
779
780        // Net income = 50000 - 40000 = 10000
781        // A = 60000, L = 20000, E = 30000, NI = 10000
782        // A = L + E + NI -> 60000 = 20000 + 30000 + 10000 ✓
783        assert!(snapshot.is_balanced);
784        assert_eq!(snapshot.net_income, dec!(10000));
785    }
786
787    #[test]
788    fn test_account_type_from_code() {
789        assert_eq!(AccountType::from_account_code("1100"), AccountType::Asset);
790        assert_eq!(
791            AccountType::from_account_code("2100"),
792            AccountType::Liability
793        );
794        assert_eq!(AccountType::from_account_code("3100"), AccountType::Equity);
795        assert_eq!(AccountType::from_account_code("4100"), AccountType::Revenue);
796        assert_eq!(AccountType::from_account_code("5100"), AccountType::Expense);
797    }
798
799    #[test]
800    fn test_account_type_from_code_with_framework_us_gaap() {
801        assert_eq!(
802            AccountType::from_account_code_with_framework("1100", "us_gaap"),
803            AccountType::Asset
804        );
805        assert_eq!(
806            AccountType::from_account_code_with_framework("4000", "us_gaap"),
807            AccountType::Revenue
808        );
809    }
810
811    #[test]
812    fn test_account_type_from_code_with_framework_french_gaap() {
813        // PCG class 1 (10x) = Equity, not Asset
814        assert_eq!(
815            AccountType::from_account_code_with_framework("101000", "french_gaap"),
816            AccountType::Equity
817        );
818        // PCG class 2 = Fixed Assets
819        assert_eq!(
820            AccountType::from_account_code_with_framework("210000", "french_gaap"),
821            AccountType::Asset
822        );
823        // PCG class 7 = Revenue
824        assert_eq!(
825            AccountType::from_account_code_with_framework("701000", "french_gaap"),
826            AccountType::Revenue
827        );
828    }
829
830    #[test]
831    fn test_account_type_from_code_with_framework_german_gaap() {
832        // SKR04 class 0 = Fixed Assets
833        assert_eq!(
834            AccountType::from_account_code_with_framework("0200", "german_gaap"),
835            AccountType::Asset
836        );
837        // SKR04 class 2 = Equity (not Liability as US GAAP would say)
838        assert_eq!(
839            AccountType::from_account_code_with_framework("2000", "german_gaap"),
840            AccountType::Equity
841        );
842        // SKR04 class 4 = Revenue
843        assert_eq!(
844            AccountType::from_account_code_with_framework("4000", "german_gaap"),
845            AccountType::Revenue
846        );
847    }
848
849    #[test]
850    fn test_balance_roll_forward() {
851        let mut balance = AccountBalance::new(
852            "1000".to_string(),
853            "1100".to_string(),
854            AccountType::Asset,
855            "USD".to_string(),
856            2022,
857            12,
858        );
859
860        balance.set_opening_balance(dec!(10000));
861        balance.apply_debit(dec!(5000));
862        balance.roll_forward();
863
864        assert_eq!(balance.opening_balance, dec!(15000));
865        assert_eq!(balance.period_debits, Decimal::ZERO);
866        assert_eq!(balance.fiscal_year, 2023);
867        assert_eq!(balance.fiscal_period, 1);
868    }
869}