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
563 .balances
564 .keys()
565 .map(std::string::String::as_str)
566 .collect();
567 for code in current.balances.keys() {
568 if !all_accounts.contains(&code.as_str()) {
569 all_accounts.push(code.as_str());
570 }
571 }
572
573 for account_code in all_accounts {
574 let prior_balance = prior
575 .balances
576 .get(account_code)
577 .map(|b| b.closing_balance)
578 .unwrap_or(Decimal::ZERO);
579
580 let current_balance = current
581 .balances
582 .get(account_code)
583 .map(|b| b.closing_balance)
584 .unwrap_or(Decimal::ZERO);
585
586 let description = current
587 .balances
588 .get(account_code)
589 .and_then(|b| b.account_description.clone())
590 .or_else(|| {
591 prior
592 .balances
593 .get(account_code)
594 .and_then(|b| b.account_description.clone())
595 });
596
597 if prior_balance != current_balance {
598 changes.push(BalanceChange::new(
599 account_code.to_string(),
600 description,
601 prior_balance,
602 current_balance,
603 significance_threshold,
604 ));
605 }
606 }
607
608 changes
609}
610
611#[cfg(test)]
612#[allow(clippy::unwrap_used)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn test_account_balance_debit_normal() {
618 let mut balance = AccountBalance::new(
619 "1000".to_string(),
620 "1100".to_string(),
621 AccountType::Asset,
622 "USD".to_string(),
623 2022,
624 6,
625 );
626
627 balance.set_opening_balance(dec!(10000));
628 balance.apply_debit(dec!(5000));
629 balance.apply_credit(dec!(2000));
630
631 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
633 }
634
635 #[test]
636 fn test_account_balance_credit_normal() {
637 let mut balance = AccountBalance::new(
638 "1000".to_string(),
639 "2100".to_string(),
640 AccountType::Liability,
641 "USD".to_string(),
642 2022,
643 6,
644 );
645
646 balance.set_opening_balance(dec!(10000));
647 balance.apply_credit(dec!(5000));
648 balance.apply_debit(dec!(2000));
649
650 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
652 }
653
654 #[test]
655 fn test_balance_snapshot_balanced() {
656 let mut snapshot = BalanceSnapshot::new(
657 "SNAP001".to_string(),
658 "1000".to_string(),
659 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
660 2022,
661 6,
662 "USD".to_string(),
663 );
664
665 let mut cash = AccountBalance::new(
667 "1000".to_string(),
668 "1100".to_string(),
669 AccountType::Asset,
670 "USD".to_string(),
671 2022,
672 6,
673 );
674 cash.closing_balance = dec!(50000);
675 snapshot.add_balance(cash);
676
677 let mut ap = AccountBalance::new(
679 "1000".to_string(),
680 "2100".to_string(),
681 AccountType::Liability,
682 "USD".to_string(),
683 2022,
684 6,
685 );
686 ap.closing_balance = dec!(20000);
687 snapshot.add_balance(ap);
688
689 let mut equity = AccountBalance::new(
691 "1000".to_string(),
692 "3100".to_string(),
693 AccountType::Equity,
694 "USD".to_string(),
695 2022,
696 6,
697 );
698 equity.closing_balance = dec!(30000);
699 snapshot.add_balance(equity);
700
701 assert!(snapshot.is_balanced);
702 assert_eq!(snapshot.total_assets, dec!(50000));
703 assert_eq!(snapshot.total_liabilities, dec!(20000));
704 assert_eq!(snapshot.total_equity, dec!(30000));
705 }
706
707 #[test]
708 fn test_balance_snapshot_with_income() {
709 let mut snapshot = BalanceSnapshot::new(
710 "SNAP001".to_string(),
711 "1000".to_string(),
712 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
713 2022,
714 6,
715 "USD".to_string(),
716 );
717
718 let mut cash = AccountBalance::new(
720 "1000".to_string(),
721 "1100".to_string(),
722 AccountType::Asset,
723 "USD".to_string(),
724 2022,
725 6,
726 );
727 cash.closing_balance = dec!(60000);
728 snapshot.add_balance(cash);
729
730 let mut ap = AccountBalance::new(
732 "1000".to_string(),
733 "2100".to_string(),
734 AccountType::Liability,
735 "USD".to_string(),
736 2022,
737 6,
738 );
739 ap.closing_balance = dec!(20000);
740 snapshot.add_balance(ap);
741
742 let mut equity = AccountBalance::new(
744 "1000".to_string(),
745 "3100".to_string(),
746 AccountType::Equity,
747 "USD".to_string(),
748 2022,
749 6,
750 );
751 equity.closing_balance = dec!(30000);
752 snapshot.add_balance(equity);
753
754 let mut revenue = AccountBalance::new(
756 "1000".to_string(),
757 "4100".to_string(),
758 AccountType::Revenue,
759 "USD".to_string(),
760 2022,
761 6,
762 );
763 revenue.closing_balance = dec!(50000);
764 snapshot.add_balance(revenue);
765
766 let mut expense = AccountBalance::new(
768 "1000".to_string(),
769 "5100".to_string(),
770 AccountType::Expense,
771 "USD".to_string(),
772 2022,
773 6,
774 );
775 expense.closing_balance = dec!(40000);
776 snapshot.add_balance(expense);
777
778 assert!(snapshot.is_balanced);
782 assert_eq!(snapshot.net_income, dec!(10000));
783 }
784
785 #[test]
786 fn test_account_type_from_code() {
787 assert_eq!(AccountType::from_account_code("1100"), AccountType::Asset);
788 assert_eq!(
789 AccountType::from_account_code("2100"),
790 AccountType::Liability
791 );
792 assert_eq!(AccountType::from_account_code("3100"), AccountType::Equity);
793 assert_eq!(AccountType::from_account_code("4100"), AccountType::Revenue);
794 assert_eq!(AccountType::from_account_code("5100"), AccountType::Expense);
795 }
796
797 #[test]
798 fn test_account_type_from_code_with_framework_us_gaap() {
799 assert_eq!(
800 AccountType::from_account_code_with_framework("1100", "us_gaap"),
801 AccountType::Asset
802 );
803 assert_eq!(
804 AccountType::from_account_code_with_framework("4000", "us_gaap"),
805 AccountType::Revenue
806 );
807 }
808
809 #[test]
810 fn test_account_type_from_code_with_framework_french_gaap() {
811 assert_eq!(
813 AccountType::from_account_code_with_framework("101000", "french_gaap"),
814 AccountType::Equity
815 );
816 assert_eq!(
818 AccountType::from_account_code_with_framework("210000", "french_gaap"),
819 AccountType::Asset
820 );
821 assert_eq!(
823 AccountType::from_account_code_with_framework("701000", "french_gaap"),
824 AccountType::Revenue
825 );
826 }
827
828 #[test]
829 fn test_account_type_from_code_with_framework_german_gaap() {
830 assert_eq!(
832 AccountType::from_account_code_with_framework("0200", "german_gaap"),
833 AccountType::Asset
834 );
835 assert_eq!(
837 AccountType::from_account_code_with_framework("2000", "german_gaap"),
838 AccountType::Equity
839 );
840 assert_eq!(
842 AccountType::from_account_code_with_framework("4000", "german_gaap"),
843 AccountType::Revenue
844 );
845 }
846
847 #[test]
848 fn test_balance_roll_forward() {
849 let mut balance = AccountBalance::new(
850 "1000".to_string(),
851 "1100".to_string(),
852 AccountType::Asset,
853 "USD".to_string(),
854 2022,
855 12,
856 );
857
858 balance.set_opening_balance(dec!(10000));
859 balance.apply_debit(dec!(5000));
860 balance.roll_forward();
861
862 assert_eq!(balance.opening_balance, dec!(15000));
863 assert_eq!(balance.period_debits, Decimal::ZERO);
864 assert_eq!(balance.fiscal_year, 2023);
865 assert_eq!(balance.fiscal_period, 1);
866 }
867}