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 {
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 pub fn is_contra_from_code(code: &str) -> bool {
229 code.contains("ACCUM") || code.contains("ALLOW") || code.contains("CONTRA")
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct BalanceSnapshot {
237 pub snapshot_id: String,
239 pub company_code: String,
241 pub as_of_date: NaiveDate,
243 pub fiscal_year: i32,
245 pub fiscal_period: u32,
247 pub currency: String,
249 pub balances: HashMap<String, AccountBalance>,
251 pub total_assets: Decimal,
253 pub total_liabilities: Decimal,
255 pub total_equity: Decimal,
257 pub total_revenue: Decimal,
259 pub total_expenses: Decimal,
261 pub net_income: Decimal,
263 pub is_balanced: bool,
265 pub balance_difference: Decimal,
267 pub created_at: NaiveDateTime,
269}
270
271impl BalanceSnapshot {
272 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 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 pub fn recalculate_totals(&mut self) {
322 self.net_income = self.total_revenue - self.total_expenses;
323
324 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 pub fn get_balance(&self, account_code: &str) -> Option<&AccountBalance> {
334 self.balances.get(account_code)
335 }
336
337 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct BalanceChange {
437 pub account_code: String,
439 pub account_description: Option<String>,
441 pub prior_balance: Decimal,
443 pub current_balance: Decimal,
445 pub change_amount: Decimal,
447 pub change_percent: Option<Decimal>,
449 pub is_significant: bool,
451}
452
453impl BalanceChange {
454 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
489pub struct AccountPeriodActivity {
490 pub account_code: String,
492 pub period_start: NaiveDate,
494 pub period_end: NaiveDate,
496 pub opening_balance: Decimal,
498 pub closing_balance: Decimal,
500 pub total_debits: Decimal,
502 pub total_credits: Decimal,
504 pub net_change: Decimal,
506 pub transaction_count: u32,
508}
509
510impl AccountPeriodActivity {
511 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 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 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
541pub fn compare_snapshots(
543 prior: &BalanceSnapshot,
544 current: &BalanceSnapshot,
545 significance_threshold: Decimal,
546) -> Vec<BalanceChange> {
547 let mut changes = Vec::new();
548
549 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)); 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)); 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 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 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 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 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 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 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 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 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 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}