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)]
614mod tests {
615 use super::*;
616
617 #[test]
618 fn test_account_balance_debit_normal() {
619 let mut balance = AccountBalance::new(
620 "1000".to_string(),
621 "1100".to_string(),
622 AccountType::Asset,
623 "USD".to_string(),
624 2022,
625 6,
626 );
627
628 balance.set_opening_balance(dec!(10000));
629 balance.apply_debit(dec!(5000));
630 balance.apply_credit(dec!(2000));
631
632 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
634 }
635
636 #[test]
637 fn test_account_balance_credit_normal() {
638 let mut balance = AccountBalance::new(
639 "1000".to_string(),
640 "2100".to_string(),
641 AccountType::Liability,
642 "USD".to_string(),
643 2022,
644 6,
645 );
646
647 balance.set_opening_balance(dec!(10000));
648 balance.apply_credit(dec!(5000));
649 balance.apply_debit(dec!(2000));
650
651 assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
653 }
654
655 #[test]
656 fn test_balance_snapshot_balanced() {
657 let mut snapshot = BalanceSnapshot::new(
658 "SNAP001".to_string(),
659 "1000".to_string(),
660 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
661 2022,
662 6,
663 "USD".to_string(),
664 );
665
666 let mut cash = AccountBalance::new(
668 "1000".to_string(),
669 "1100".to_string(),
670 AccountType::Asset,
671 "USD".to_string(),
672 2022,
673 6,
674 );
675 cash.closing_balance = dec!(50000);
676 snapshot.add_balance(cash);
677
678 let mut ap = AccountBalance::new(
680 "1000".to_string(),
681 "2100".to_string(),
682 AccountType::Liability,
683 "USD".to_string(),
684 2022,
685 6,
686 );
687 ap.closing_balance = dec!(20000);
688 snapshot.add_balance(ap);
689
690 let mut equity = AccountBalance::new(
692 "1000".to_string(),
693 "3100".to_string(),
694 AccountType::Equity,
695 "USD".to_string(),
696 2022,
697 6,
698 );
699 equity.closing_balance = dec!(30000);
700 snapshot.add_balance(equity);
701
702 assert!(snapshot.is_balanced);
703 assert_eq!(snapshot.total_assets, dec!(50000));
704 assert_eq!(snapshot.total_liabilities, dec!(20000));
705 assert_eq!(snapshot.total_equity, dec!(30000));
706 }
707
708 #[test]
709 fn test_balance_snapshot_with_income() {
710 let mut snapshot = BalanceSnapshot::new(
711 "SNAP001".to_string(),
712 "1000".to_string(),
713 NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
714 2022,
715 6,
716 "USD".to_string(),
717 );
718
719 let mut cash = AccountBalance::new(
721 "1000".to_string(),
722 "1100".to_string(),
723 AccountType::Asset,
724 "USD".to_string(),
725 2022,
726 6,
727 );
728 cash.closing_balance = dec!(60000);
729 snapshot.add_balance(cash);
730
731 let mut ap = AccountBalance::new(
733 "1000".to_string(),
734 "2100".to_string(),
735 AccountType::Liability,
736 "USD".to_string(),
737 2022,
738 6,
739 );
740 ap.closing_balance = dec!(20000);
741 snapshot.add_balance(ap);
742
743 let mut equity = AccountBalance::new(
745 "1000".to_string(),
746 "3100".to_string(),
747 AccountType::Equity,
748 "USD".to_string(),
749 2022,
750 6,
751 );
752 equity.closing_balance = dec!(30000);
753 snapshot.add_balance(equity);
754
755 let mut revenue = AccountBalance::new(
757 "1000".to_string(),
758 "4100".to_string(),
759 AccountType::Revenue,
760 "USD".to_string(),
761 2022,
762 6,
763 );
764 revenue.closing_balance = dec!(50000);
765 snapshot.add_balance(revenue);
766
767 let mut expense = AccountBalance::new(
769 "1000".to_string(),
770 "5100".to_string(),
771 AccountType::Expense,
772 "USD".to_string(),
773 2022,
774 6,
775 );
776 expense.closing_balance = dec!(40000);
777 snapshot.add_balance(expense);
778
779 assert!(snapshot.is_balanced);
783 assert_eq!(snapshot.net_income, dec!(10000));
784 }
785
786 #[test]
787 fn test_account_type_from_code() {
788 assert_eq!(AccountType::from_account_code("1100"), AccountType::Asset);
789 assert_eq!(
790 AccountType::from_account_code("2100"),
791 AccountType::Liability
792 );
793 assert_eq!(AccountType::from_account_code("3100"), AccountType::Equity);
794 assert_eq!(AccountType::from_account_code("4100"), AccountType::Revenue);
795 assert_eq!(AccountType::from_account_code("5100"), AccountType::Expense);
796 }
797
798 #[test]
799 fn test_account_type_from_code_with_framework_us_gaap() {
800 assert_eq!(
801 AccountType::from_account_code_with_framework("1100", "us_gaap"),
802 AccountType::Asset
803 );
804 assert_eq!(
805 AccountType::from_account_code_with_framework("4000", "us_gaap"),
806 AccountType::Revenue
807 );
808 }
809
810 #[test]
811 fn test_account_type_from_code_with_framework_french_gaap() {
812 assert_eq!(
814 AccountType::from_account_code_with_framework("101000", "french_gaap"),
815 AccountType::Equity
816 );
817 assert_eq!(
819 AccountType::from_account_code_with_framework("210000", "french_gaap"),
820 AccountType::Asset
821 );
822 assert_eq!(
824 AccountType::from_account_code_with_framework("701000", "french_gaap"),
825 AccountType::Revenue
826 );
827 }
828
829 #[test]
830 fn test_account_type_from_code_with_framework_german_gaap() {
831 assert_eq!(
833 AccountType::from_account_code_with_framework("0200", "german_gaap"),
834 AccountType::Asset
835 );
836 assert_eq!(
838 AccountType::from_account_code_with_framework("2000", "german_gaap"),
839 AccountType::Equity
840 );
841 assert_eq!(
843 AccountType::from_account_code_with_framework("4000", "german_gaap"),
844 AccountType::Revenue
845 );
846 }
847
848 #[test]
849 fn test_balance_roll_forward() {
850 let mut balance = AccountBalance::new(
851 "1000".to_string(),
852 "1100".to_string(),
853 AccountType::Asset,
854 "USD".to_string(),
855 2022,
856 12,
857 );
858
859 balance.set_opening_balance(dec!(10000));
860 balance.apply_debit(dec!(5000));
861 balance.roll_forward();
862
863 assert_eq!(balance.opening_balance, dec!(15000));
864 assert_eq!(balance.period_debits, Decimal::ZERO);
865 assert_eq!(balance.fiscal_year, 2023);
866 assert_eq!(balance.fiscal_period, 1);
867 }
868}