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 #[serde(with = "crate::serde_timestamp::naive")]
44 pub last_updated: NaiveDateTime,
45}
46
47impl AccountBalance {
48 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 pub fn apply_debit(&mut self, amount: Decimal) {
79 self.period_debits += amount;
80 self.recalculate_closing();
81 }
82
83 pub fn apply_credit(&mut self, amount: Decimal) {
85 self.period_credits += amount;
86 self.recalculate_closing();
87 }
88
89 fn recalculate_closing(&mut self) {
91 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 pub fn set_opening_balance(&mut self, balance: Decimal) {
114 self.opening_balance = balance;
115 self.recalculate_closing();
116 }
117
118 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 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 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 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 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 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 pub fn is_income_statement(&self) -> bool {
184 matches!(
185 self.account_type,
186 AccountType::Revenue | AccountType::Expense
187 )
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
193#[serde(rename_all = "snake_case")]
194pub enum AccountType {
195 #[default]
197 Asset,
198 ContraAsset,
200 Liability,
202 ContraLiability,
204 Equity,
206 ContraEquity,
208 Revenue,
210 Expense,
212}
213
214impl AccountType {
215 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 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 pub fn is_contra_from_code(code: &str) -> bool {
242 code.contains("ACCUM") || code.contains("ALLOW") || code.contains("CONTRA")
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct BalanceSnapshot {
250 pub snapshot_id: String,
252 pub company_code: String,
254 pub as_of_date: NaiveDate,
256 pub fiscal_year: i32,
258 pub fiscal_period: u32,
260 pub currency: String,
262 pub balances: HashMap<String, AccountBalance>,
264 pub total_assets: Decimal,
266 pub total_liabilities: Decimal,
268 pub total_equity: Decimal,
270 pub total_revenue: Decimal,
272 pub total_expenses: Decimal,
274 pub net_income: Decimal,
276 pub is_balanced: bool,
278 pub balance_difference: Decimal,
280 #[serde(with = "crate::serde_timestamp::naive")]
282 pub created_at: NaiveDateTime,
283}
284
285impl BalanceSnapshot {
286 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 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 pub fn recalculate_totals(&mut self) {
336 self.net_income = self.total_revenue - self.total_expenses;
337
338 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 pub fn get_balance(&self, account_code: &str) -> Option<&AccountBalance> {
348 self.balances.get(account_code)
349 }
350
351 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct BalanceChange {
451 pub account_code: String,
453 pub account_description: Option<String>,
455 pub prior_balance: Decimal,
457 pub current_balance: Decimal,
459 pub change_amount: Decimal,
461 pub change_percent: Option<Decimal>,
463 pub is_significant: bool,
465}
466
467impl BalanceChange {
468 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
503pub struct AccountPeriodActivity {
504 pub account_code: String,
506 pub period_start: NaiveDate,
508 pub period_end: NaiveDate,
510 pub opening_balance: Decimal,
512 pub closing_balance: Decimal,
514 pub total_debits: Decimal,
516 pub total_credits: Decimal,
518 pub net_change: Decimal,
520 pub transaction_count: u32,
522}
523
524impl AccountPeriodActivity {
525 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 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 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
555pub fn compare_snapshots(
557 prior: &BalanceSnapshot,
558 current: &BalanceSnapshot,
559 significance_threshold: Decimal,
560) -> Vec<BalanceChange> {
561 let mut changes = Vec::new();
562
563 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)); 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)); 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 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 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 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 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 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 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 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 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 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 assert_eq!(
815 AccountType::from_account_code_with_framework("101000", "french_gaap"),
816 AccountType::Equity
817 );
818 assert_eq!(
820 AccountType::from_account_code_with_framework("210000", "french_gaap"),
821 AccountType::Asset
822 );
823 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 assert_eq!(
834 AccountType::from_account_code_with_framework("0200", "german_gaap"),
835 AccountType::Asset
836 );
837 assert_eq!(
839 AccountType::from_account_code_with_framework("2000", "german_gaap"),
840 AccountType::Equity
841 );
842 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}