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