1use chrono::{NaiveDate, NaiveDateTime};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AccountBalance {
12 pub company_code: String,
14 pub account_code: String,
16 pub account_description: Option<String>,
18 pub account_type: AccountType,
20 pub currency: String,
22 pub opening_balance: Decimal,
24 pub period_debits: Decimal,
26 pub period_credits: Decimal,
28 pub closing_balance: Decimal,
30 pub fiscal_year: i32,
32 pub fiscal_period: u32,
34 pub group_currency_balance: Option<Decimal>,
36 pub exchange_rate: Option<Decimal>,
38 pub cost_center: Option<String>,
40 pub profit_center: Option<String>,
42 pub last_updated: NaiveDateTime,
44}
45
46impl AccountBalance {
47 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 pub fn apply_debit(&mut self, amount: Decimal) {
78 self.period_debits += amount;
79 self.recalculate_closing();
80 }
81
82 pub fn apply_credit(&mut self, amount: Decimal) {
84 self.period_credits += amount;
85 self.recalculate_closing();
86 }
87
88 fn recalculate_closing(&mut self) {
90 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 pub fn set_opening_balance(&mut self, balance: Decimal) {
113 self.opening_balance = balance;
114 self.recalculate_closing();
115 }
116
117 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 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 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 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 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 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 pub fn is_income_statement(&self) -> bool {
183 matches!(
184 self.account_type,
185 AccountType::Revenue | AccountType::Expense
186 )
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum AccountType {
194 #[default]
196 Asset,
197 ContraAsset,
199 Liability,
201 ContraLiability,
203 Equity,
205 ContraEquity,
207 Revenue,
209 Expense,
211}
212
213impl AccountType {
214 pub fn from_account_code(code: &str) -> Self {
219 let first_char = code.chars().next().unwrap_or('0');
220 match first_char {
221 '1' => Self::Asset,
222 '2' => Self::Liability,
223 '3' => Self::Equity,
224 '4' => Self::Revenue,
225 '5' | '6' | '7' | '8' => Self::Expense,
226 _ => Self::Asset,
227 }
228 }
229
230 pub fn from_account_code_with_framework(code: &str, framework: &str) -> Self {
235 crate::framework_accounts::FrameworkAccounts::for_framework(framework)
236 .classify_account_type(code)
237 }
238
239 pub fn is_contra_from_code(code: &str) -> bool {
241 code.contains("ACCUM") || code.contains("ALLOW") || code.contains("CONTRA")
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct BalanceSnapshot {
249 pub snapshot_id: String,
251 pub company_code: String,
253 pub as_of_date: NaiveDate,
255 pub fiscal_year: i32,
257 pub fiscal_period: u32,
259 pub currency: String,
261 pub balances: HashMap<String, AccountBalance>,
263 pub total_assets: Decimal,
265 pub total_liabilities: Decimal,
267 pub total_equity: Decimal,
269 pub total_revenue: Decimal,
271 pub total_expenses: Decimal,
273 pub net_income: Decimal,
275 pub is_balanced: bool,
277 pub balance_difference: Decimal,
279 pub created_at: NaiveDateTime,
281}
282
283impl BalanceSnapshot {
284 pub fn new(
286 snapshot_id: String,
287 company_code: String,
288 as_of_date: NaiveDate,
289 fiscal_year: i32,
290 fiscal_period: u32,
291 currency: String,
292 ) -> Self {
293 Self {
294 snapshot_id,
295 company_code,
296 as_of_date,
297 fiscal_year,
298 fiscal_period,
299 currency,
300 balances: HashMap::new(),
301 total_assets: Decimal::ZERO,
302 total_liabilities: Decimal::ZERO,
303 total_equity: Decimal::ZERO,
304 total_revenue: Decimal::ZERO,
305 total_expenses: Decimal::ZERO,
306 net_income: Decimal::ZERO,
307 is_balanced: true,
308 balance_difference: Decimal::ZERO,
309 created_at: chrono::Utc::now().naive_utc(),
310 }
311 }
312
313 pub fn add_balance(&mut self, balance: AccountBalance) {
315 let closing = balance.closing_balance;
316
317 match balance.account_type {
318 AccountType::Asset => self.total_assets += closing,
319 AccountType::ContraAsset => self.total_assets -= closing,
320 AccountType::Liability => self.total_liabilities += closing,
321 AccountType::ContraLiability => self.total_liabilities -= closing,
322 AccountType::Equity => self.total_equity += closing,
323 AccountType::ContraEquity => self.total_equity -= closing,
324 AccountType::Revenue => self.total_revenue += closing,
325 AccountType::Expense => self.total_expenses += closing,
326 }
327
328 self.balances.insert(balance.account_code.clone(), balance);
329 self.recalculate_totals();
330 }
331
332 pub fn recalculate_totals(&mut self) {
334 self.net_income = self.total_revenue - self.total_expenses;
335
336 let total_equity_with_income = self.total_equity + self.net_income;
339 self.balance_difference =
340 self.total_assets - self.total_liabilities - total_equity_with_income;
341 self.is_balanced = self.balance_difference.abs() < dec!(0.01);
342 }
343
344 pub fn get_balance(&self, account_code: &str) -> Option<&AccountBalance> {
346 self.balances.get(account_code)
347 }
348
349 pub fn get_asset_balances(&self) -> Vec<&AccountBalance> {
351 self.balances
352 .values()
353 .filter(|b| {
354 matches!(
355 b.account_type,
356 AccountType::Asset | AccountType::ContraAsset
357 )
358 })
359 .collect()
360 }
361
362 pub fn get_liability_balances(&self) -> Vec<&AccountBalance> {
364 self.balances
365 .values()
366 .filter(|b| {
367 matches!(
368 b.account_type,
369 AccountType::Liability | AccountType::ContraLiability
370 )
371 })
372 .collect()
373 }
374
375 pub fn get_equity_balances(&self) -> Vec<&AccountBalance> {
377 self.balances
378 .values()
379 .filter(|b| {
380 matches!(
381 b.account_type,
382 AccountType::Equity | AccountType::ContraEquity
383 )
384 })
385 .collect()
386 }
387
388 pub fn get_income_statement_balances(&self) -> Vec<&AccountBalance> {
390 self.balances
391 .values()
392 .filter(|b| b.is_income_statement())
393 .collect()
394 }
395
396 pub fn current_ratio(
398 &self,
399 current_asset_accounts: &[&str],
400 current_liability_accounts: &[&str],
401 ) -> Option<Decimal> {
402 let current_assets: Decimal = current_asset_accounts
403 .iter()
404 .filter_map(|code| self.balances.get(*code))
405 .map(|b| b.closing_balance)
406 .sum();
407
408 let current_liabilities: Decimal = current_liability_accounts
409 .iter()
410 .filter_map(|code| self.balances.get(*code))
411 .map(|b| b.closing_balance)
412 .sum();
413
414 if current_liabilities != Decimal::ZERO {
415 Some(current_assets / current_liabilities)
416 } else {
417 None
418 }
419 }
420
421 pub fn debt_to_equity_ratio(&self) -> Option<Decimal> {
423 if self.total_equity != Decimal::ZERO {
424 Some(self.total_liabilities / self.total_equity)
425 } else {
426 None
427 }
428 }
429
430 pub fn gross_margin(&self, cogs_accounts: &[&str]) -> Option<Decimal> {
432 if self.total_revenue == Decimal::ZERO {
433 return None;
434 }
435
436 let cogs: Decimal = cogs_accounts
437 .iter()
438 .filter_map(|code| self.balances.get(*code))
439 .map(|b| b.closing_balance)
440 .sum();
441
442 Some((self.total_revenue - cogs) / self.total_revenue)
443 }
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct BalanceChange {
449 pub account_code: String,
451 pub account_description: Option<String>,
453 pub prior_balance: Decimal,
455 pub current_balance: Decimal,
457 pub change_amount: Decimal,
459 pub change_percent: Option<Decimal>,
461 pub is_significant: bool,
463}
464
465impl BalanceChange {
466 pub fn new(
468 account_code: String,
469 account_description: Option<String>,
470 prior_balance: Decimal,
471 current_balance: Decimal,
472 significance_threshold: Decimal,
473 ) -> Self {
474 let change_amount = current_balance - prior_balance;
475 let change_percent = if prior_balance != Decimal::ZERO {
476 Some((change_amount / prior_balance.abs()) * dec!(100))
477 } else {
478 None
479 };
480
481 let is_significant = change_amount.abs() >= significance_threshold
482 || change_percent.is_some_and(|p| p.abs() >= dec!(10));
483
484 Self {
485 account_code,
486 account_description,
487 prior_balance,
488 current_balance,
489 change_amount,
490 change_percent,
491 is_significant,
492 }
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, Default)]
501pub struct AccountPeriodActivity {
502 pub account_code: String,
504 pub period_start: NaiveDate,
506 pub period_end: NaiveDate,
508 pub opening_balance: Decimal,
510 pub closing_balance: Decimal,
512 pub total_debits: Decimal,
514 pub total_credits: Decimal,
516 pub net_change: Decimal,
518 pub transaction_count: u32,
520}
521
522impl AccountPeriodActivity {
523 pub fn new(account_code: String, period_start: NaiveDate, period_end: NaiveDate) -> Self {
525 Self {
526 account_code,
527 period_start,
528 period_end,
529 opening_balance: Decimal::ZERO,
530 closing_balance: Decimal::ZERO,
531 total_debits: Decimal::ZERO,
532 total_credits: Decimal::ZERO,
533 net_change: Decimal::ZERO,
534 transaction_count: 0,
535 }
536 }
537
538 pub fn add_debit(&mut self, amount: Decimal) {
540 self.total_debits += amount;
541 self.net_change += amount;
542 self.transaction_count += 1;
543 }
544
545 pub fn add_credit(&mut self, amount: Decimal) {
547 self.total_credits += amount;
548 self.net_change -= amount;
549 self.transaction_count += 1;
550 }
551}
552
553pub fn compare_snapshots(
555 prior: &BalanceSnapshot,
556 current: &BalanceSnapshot,
557 significance_threshold: Decimal,
558) -> Vec<BalanceChange> {
559 let mut changes = Vec::new();
560
561 let mut all_accounts: Vec<&str> = prior.balances.keys().map(|s| s.as_str()).collect();
563 for code in current.balances.keys() {
564 if !all_accounts.contains(&code.as_str()) {
565 all_accounts.push(code.as_str());
566 }
567 }
568
569 for account_code in all_accounts {
570 let prior_balance = prior
571 .balances
572 .get(account_code)
573 .map(|b| b.closing_balance)
574 .unwrap_or(Decimal::ZERO);
575
576 let current_balance = current
577 .balances
578 .get(account_code)
579 .map(|b| b.closing_balance)
580 .unwrap_or(Decimal::ZERO);
581
582 let description = current
583 .balances
584 .get(account_code)
585 .and_then(|b| b.account_description.clone())
586 .or_else(|| {
587 prior
588 .balances
589 .get(account_code)
590 .and_then(|b| b.account_description.clone())
591 });
592
593 if prior_balance != current_balance {
594 changes.push(BalanceChange::new(
595 account_code.to_string(),
596 description,
597 prior_balance,
598 current_balance,
599 significance_threshold,
600 ));
601 }
602 }
603
604 changes
605}
606
607#[cfg(test)]
608#[allow(clippy::unwrap_used)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn test_account_balance_debit_normal() {
614 let mut balance = AccountBalance::new(
615 "1000".to_string(),
616 "1100".to_string(),
617 AccountType::Asset,
618 "USD".to_string(),
619 2022,
620 6,
621 );
622
623 balance.set_opening_balance(dec!(10000));
624 balance.apply_debit(dec!(5000));
625 balance.apply_credit(dec!(2000));
626
627 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
629 }
630
631 #[test]
632 fn test_account_balance_credit_normal() {
633 let mut balance = AccountBalance::new(
634 "1000".to_string(),
635 "2100".to_string(),
636 AccountType::Liability,
637 "USD".to_string(),
638 2022,
639 6,
640 );
641
642 balance.set_opening_balance(dec!(10000));
643 balance.apply_credit(dec!(5000));
644 balance.apply_debit(dec!(2000));
645
646 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
648 }
649
650 #[test]
651 fn test_balance_snapshot_balanced() {
652 let mut snapshot = BalanceSnapshot::new(
653 "SNAP001".to_string(),
654 "1000".to_string(),
655 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
656 2022,
657 6,
658 "USD".to_string(),
659 );
660
661 let mut cash = AccountBalance::new(
663 "1000".to_string(),
664 "1100".to_string(),
665 AccountType::Asset,
666 "USD".to_string(),
667 2022,
668 6,
669 );
670 cash.closing_balance = dec!(50000);
671 snapshot.add_balance(cash);
672
673 let mut ap = AccountBalance::new(
675 "1000".to_string(),
676 "2100".to_string(),
677 AccountType::Liability,
678 "USD".to_string(),
679 2022,
680 6,
681 );
682 ap.closing_balance = dec!(20000);
683 snapshot.add_balance(ap);
684
685 let mut equity = AccountBalance::new(
687 "1000".to_string(),
688 "3100".to_string(),
689 AccountType::Equity,
690 "USD".to_string(),
691 2022,
692 6,
693 );
694 equity.closing_balance = dec!(30000);
695 snapshot.add_balance(equity);
696
697 assert!(snapshot.is_balanced);
698 assert_eq!(snapshot.total_assets, dec!(50000));
699 assert_eq!(snapshot.total_liabilities, dec!(20000));
700 assert_eq!(snapshot.total_equity, dec!(30000));
701 }
702
703 #[test]
704 fn test_balance_snapshot_with_income() {
705 let mut snapshot = BalanceSnapshot::new(
706 "SNAP001".to_string(),
707 "1000".to_string(),
708 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
709 2022,
710 6,
711 "USD".to_string(),
712 );
713
714 let mut cash = AccountBalance::new(
716 "1000".to_string(),
717 "1100".to_string(),
718 AccountType::Asset,
719 "USD".to_string(),
720 2022,
721 6,
722 );
723 cash.closing_balance = dec!(60000);
724 snapshot.add_balance(cash);
725
726 let mut ap = AccountBalance::new(
728 "1000".to_string(),
729 "2100".to_string(),
730 AccountType::Liability,
731 "USD".to_string(),
732 2022,
733 6,
734 );
735 ap.closing_balance = dec!(20000);
736 snapshot.add_balance(ap);
737
738 let mut equity = AccountBalance::new(
740 "1000".to_string(),
741 "3100".to_string(),
742 AccountType::Equity,
743 "USD".to_string(),
744 2022,
745 6,
746 );
747 equity.closing_balance = dec!(30000);
748 snapshot.add_balance(equity);
749
750 let mut revenue = AccountBalance::new(
752 "1000".to_string(),
753 "4100".to_string(),
754 AccountType::Revenue,
755 "USD".to_string(),
756 2022,
757 6,
758 );
759 revenue.closing_balance = dec!(50000);
760 snapshot.add_balance(revenue);
761
762 let mut expense = AccountBalance::new(
764 "1000".to_string(),
765 "5100".to_string(),
766 AccountType::Expense,
767 "USD".to_string(),
768 2022,
769 6,
770 );
771 expense.closing_balance = dec!(40000);
772 snapshot.add_balance(expense);
773
774 assert!(snapshot.is_balanced);
778 assert_eq!(snapshot.net_income, dec!(10000));
779 }
780
781 #[test]
782 fn test_account_type_from_code() {
783 assert_eq!(AccountType::from_account_code("1100"), AccountType::Asset);
784 assert_eq!(
785 AccountType::from_account_code("2100"),
786 AccountType::Liability
787 );
788 assert_eq!(AccountType::from_account_code("3100"), AccountType::Equity);
789 assert_eq!(AccountType::from_account_code("4100"), AccountType::Revenue);
790 assert_eq!(AccountType::from_account_code("5100"), AccountType::Expense);
791 }
792
793 #[test]
794 fn test_account_type_from_code_with_framework_us_gaap() {
795 assert_eq!(
796 AccountType::from_account_code_with_framework("1100", "us_gaap"),
797 AccountType::Asset
798 );
799 assert_eq!(
800 AccountType::from_account_code_with_framework("4000", "us_gaap"),
801 AccountType::Revenue
802 );
803 }
804
805 #[test]
806 fn test_account_type_from_code_with_framework_french_gaap() {
807 assert_eq!(
809 AccountType::from_account_code_with_framework("101000", "french_gaap"),
810 AccountType::Equity
811 );
812 assert_eq!(
814 AccountType::from_account_code_with_framework("210000", "french_gaap"),
815 AccountType::Asset
816 );
817 assert_eq!(
819 AccountType::from_account_code_with_framework("701000", "french_gaap"),
820 AccountType::Revenue
821 );
822 }
823
824 #[test]
825 fn test_account_type_from_code_with_framework_german_gaap() {
826 assert_eq!(
828 AccountType::from_account_code_with_framework("0200", "german_gaap"),
829 AccountType::Asset
830 );
831 assert_eq!(
833 AccountType::from_account_code_with_framework("2000", "german_gaap"),
834 AccountType::Equity
835 );
836 assert_eq!(
838 AccountType::from_account_code_with_framework("4000", "german_gaap"),
839 AccountType::Revenue
840 );
841 }
842
843 #[test]
844 fn test_balance_roll_forward() {
845 let mut balance = AccountBalance::new(
846 "1000".to_string(),
847 "1100".to_string(),
848 AccountType::Asset,
849 "USD".to_string(),
850 2022,
851 12,
852 );
853
854 balance.set_opening_balance(dec!(10000));
855 balance.apply_debit(dec!(5000));
856 balance.roll_forward();
857
858 assert_eq!(balance.opening_balance, dec!(15000));
859 assert_eq!(balance.period_debits, Decimal::ZERO);
860 assert_eq!(balance.fiscal_year, 2023);
861 assert_eq!(balance.fiscal_period, 1);
862 }
863}