use chrono::{NaiveDate, NaiveDateTime};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountBalance {
pub company_code: String,
pub account_code: String,
pub account_description: Option<String>,
pub account_type: AccountType,
pub currency: String,
pub opening_balance: Decimal,
pub period_debits: Decimal,
pub period_credits: Decimal,
pub closing_balance: Decimal,
pub fiscal_year: i32,
pub fiscal_period: u32,
pub group_currency_balance: Option<Decimal>,
pub exchange_rate: Option<Decimal>,
pub cost_center: Option<String>,
pub profit_center: Option<String>,
#[serde(with = "crate::serde_timestamp::naive")]
pub last_updated: NaiveDateTime,
}
impl AccountBalance {
pub fn new(
company_code: String,
account_code: String,
account_type: AccountType,
currency: String,
fiscal_year: i32,
fiscal_period: u32,
) -> Self {
Self {
company_code,
account_code,
account_description: None,
account_type,
currency,
opening_balance: Decimal::ZERO,
period_debits: Decimal::ZERO,
period_credits: Decimal::ZERO,
closing_balance: Decimal::ZERO,
fiscal_year,
fiscal_period,
group_currency_balance: None,
exchange_rate: None,
cost_center: None,
profit_center: None,
last_updated: chrono::Utc::now().naive_utc(),
}
}
pub fn apply_debit(&mut self, amount: Decimal) {
self.period_debits += amount;
self.recalculate_closing();
}
pub fn apply_credit(&mut self, amount: Decimal) {
self.period_credits += amount;
self.recalculate_closing();
}
fn recalculate_closing(&mut self) {
match self.account_type {
AccountType::Asset
| AccountType::Expense
| AccountType::ContraLiability
| AccountType::ContraEquity => {
self.closing_balance =
self.opening_balance + self.period_debits - self.period_credits;
}
AccountType::Liability
| AccountType::Equity
| AccountType::Revenue
| AccountType::ContraAsset => {
self.closing_balance =
self.opening_balance - self.period_debits + self.period_credits;
}
}
self.last_updated = chrono::Utc::now().naive_utc();
}
pub fn set_opening_balance(&mut self, balance: Decimal) {
self.opening_balance = balance;
self.recalculate_closing();
}
pub fn net_change(&self) -> Decimal {
match self.account_type {
AccountType::Asset
| AccountType::Expense
| AccountType::ContraLiability
| AccountType::ContraEquity => self.period_debits - self.period_credits,
AccountType::Liability
| AccountType::Equity
| AccountType::Revenue
| AccountType::ContraAsset => self.period_credits - self.period_debits,
}
}
pub fn is_debit_normal(&self) -> bool {
matches!(
self.account_type,
AccountType::Asset
| AccountType::Expense
| AccountType::ContraLiability
| AccountType::ContraEquity
)
}
pub fn normal_balance(&self) -> Decimal {
if self.is_debit_normal() {
self.closing_balance
} else {
-self.closing_balance
}
}
pub fn roll_forward(&mut self) {
self.opening_balance = self.closing_balance;
self.period_debits = Decimal::ZERO;
self.period_credits = Decimal::ZERO;
if self.fiscal_period == 12 {
self.fiscal_period = 1;
self.fiscal_year += 1;
} else {
self.fiscal_period += 1;
}
self.last_updated = chrono::Utc::now().naive_utc();
}
pub fn is_balance_sheet(&self) -> bool {
matches!(
self.account_type,
AccountType::Asset
| AccountType::Liability
| AccountType::Equity
| AccountType::ContraAsset
| AccountType::ContraLiability
| AccountType::ContraEquity
)
}
pub fn is_income_statement(&self) -> bool {
matches!(
self.account_type,
AccountType::Revenue | AccountType::Expense
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AccountType {
#[default]
Asset,
ContraAsset,
Liability,
ContraLiability,
Equity,
ContraEquity,
Revenue,
Expense,
}
impl AccountType {
pub fn from_account_code(code: &str) -> Self {
let first_char = code.chars().next().unwrap_or('0');
match first_char {
'1' => Self::Asset,
'2' => Self::Liability,
'3' => Self::Equity,
'4' => Self::Revenue,
'5' | '6' | '7' | '8' => Self::Expense,
_ => Self::Asset,
}
}
pub fn from_account_code_with_framework(code: &str, framework: &str) -> Self {
crate::framework_accounts::FrameworkAccounts::for_framework(framework)
.classify_account_type(code)
}
pub fn is_contra_from_code(code: &str) -> bool {
code.contains("ACCUM") || code.contains("ALLOW") || code.contains("CONTRA")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceSnapshot {
pub snapshot_id: String,
pub company_code: String,
pub as_of_date: NaiveDate,
pub fiscal_year: i32,
pub fiscal_period: u32,
pub currency: String,
pub balances: HashMap<String, AccountBalance>,
pub total_assets: Decimal,
pub total_liabilities: Decimal,
pub total_equity: Decimal,
pub total_revenue: Decimal,
pub total_expenses: Decimal,
pub net_income: Decimal,
pub is_balanced: bool,
pub balance_difference: Decimal,
#[serde(with = "crate::serde_timestamp::naive")]
pub created_at: NaiveDateTime,
}
impl BalanceSnapshot {
pub fn new(
snapshot_id: String,
company_code: String,
as_of_date: NaiveDate,
fiscal_year: i32,
fiscal_period: u32,
currency: String,
) -> Self {
Self {
snapshot_id,
company_code,
as_of_date,
fiscal_year,
fiscal_period,
currency,
balances: HashMap::new(),
total_assets: Decimal::ZERO,
total_liabilities: Decimal::ZERO,
total_equity: Decimal::ZERO,
total_revenue: Decimal::ZERO,
total_expenses: Decimal::ZERO,
net_income: Decimal::ZERO,
is_balanced: true,
balance_difference: Decimal::ZERO,
created_at: chrono::Utc::now().naive_utc(),
}
}
pub fn add_balance(&mut self, balance: AccountBalance) {
let closing = balance.closing_balance;
match balance.account_type {
AccountType::Asset => self.total_assets += closing,
AccountType::ContraAsset => self.total_assets -= closing,
AccountType::Liability => self.total_liabilities += closing,
AccountType::ContraLiability => self.total_liabilities -= closing,
AccountType::Equity => self.total_equity += closing,
AccountType::ContraEquity => self.total_equity -= closing,
AccountType::Revenue => self.total_revenue += closing,
AccountType::Expense => self.total_expenses += closing,
}
self.balances.insert(balance.account_code.clone(), balance);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.net_income = self.total_revenue - self.total_expenses;
let total_equity_with_income = self.total_equity + self.net_income;
self.balance_difference =
self.total_assets - self.total_liabilities - total_equity_with_income;
self.is_balanced = self.balance_difference.abs() < dec!(0.01);
}
pub fn get_balance(&self, account_code: &str) -> Option<&AccountBalance> {
self.balances.get(account_code)
}
pub fn get_asset_balances(&self) -> Vec<&AccountBalance> {
self.balances
.values()
.filter(|b| {
matches!(
b.account_type,
AccountType::Asset | AccountType::ContraAsset
)
})
.collect()
}
pub fn get_liability_balances(&self) -> Vec<&AccountBalance> {
self.balances
.values()
.filter(|b| {
matches!(
b.account_type,
AccountType::Liability | AccountType::ContraLiability
)
})
.collect()
}
pub fn get_equity_balances(&self) -> Vec<&AccountBalance> {
self.balances
.values()
.filter(|b| {
matches!(
b.account_type,
AccountType::Equity | AccountType::ContraEquity
)
})
.collect()
}
pub fn get_income_statement_balances(&self) -> Vec<&AccountBalance> {
self.balances
.values()
.filter(|b| b.is_income_statement())
.collect()
}
pub fn current_ratio(
&self,
current_asset_accounts: &[&str],
current_liability_accounts: &[&str],
) -> Option<Decimal> {
let current_assets: Decimal = current_asset_accounts
.iter()
.filter_map(|code| self.balances.get(*code))
.map(|b| b.closing_balance)
.sum();
let current_liabilities: Decimal = current_liability_accounts
.iter()
.filter_map(|code| self.balances.get(*code))
.map(|b| b.closing_balance)
.sum();
if current_liabilities != Decimal::ZERO {
Some(current_assets / current_liabilities)
} else {
None
}
}
pub fn debt_to_equity_ratio(&self) -> Option<Decimal> {
if self.total_equity != Decimal::ZERO {
Some(self.total_liabilities / self.total_equity)
} else {
None
}
}
pub fn gross_margin(&self, cogs_accounts: &[&str]) -> Option<Decimal> {
if self.total_revenue == Decimal::ZERO {
return None;
}
let cogs: Decimal = cogs_accounts
.iter()
.filter_map(|code| self.balances.get(*code))
.map(|b| b.closing_balance)
.sum();
Some((self.total_revenue - cogs) / self.total_revenue)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceChange {
pub account_code: String,
pub account_description: Option<String>,
pub prior_balance: Decimal,
pub current_balance: Decimal,
pub change_amount: Decimal,
pub change_percent: Option<Decimal>,
pub is_significant: bool,
}
impl BalanceChange {
pub fn new(
account_code: String,
account_description: Option<String>,
prior_balance: Decimal,
current_balance: Decimal,
significance_threshold: Decimal,
) -> Self {
let change_amount = current_balance - prior_balance;
let change_percent = if prior_balance != Decimal::ZERO {
Some((change_amount / prior_balance.abs()) * dec!(100))
} else {
None
};
let is_significant = change_amount.abs() >= significance_threshold
|| change_percent.is_some_and(|p| p.abs() >= dec!(10));
Self {
account_code,
account_description,
prior_balance,
current_balance,
change_amount,
change_percent,
is_significant,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountPeriodActivity {
pub account_code: String,
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub opening_balance: Decimal,
pub closing_balance: Decimal,
pub total_debits: Decimal,
pub total_credits: Decimal,
pub net_change: Decimal,
pub transaction_count: u32,
}
impl AccountPeriodActivity {
pub fn new(account_code: String, period_start: NaiveDate, period_end: NaiveDate) -> Self {
Self {
account_code,
period_start,
period_end,
opening_balance: Decimal::ZERO,
closing_balance: Decimal::ZERO,
total_debits: Decimal::ZERO,
total_credits: Decimal::ZERO,
net_change: Decimal::ZERO,
transaction_count: 0,
}
}
pub fn add_debit(&mut self, amount: Decimal) {
self.total_debits += amount;
self.net_change += amount;
self.transaction_count += 1;
}
pub fn add_credit(&mut self, amount: Decimal) {
self.total_credits += amount;
self.net_change -= amount;
self.transaction_count += 1;
}
}
pub fn compare_snapshots(
prior: &BalanceSnapshot,
current: &BalanceSnapshot,
significance_threshold: Decimal,
) -> Vec<BalanceChange> {
let mut changes = Vec::new();
let mut all_accounts: Vec<&str> = prior
.balances
.keys()
.map(std::string::String::as_str)
.collect();
for code in current.balances.keys() {
if !all_accounts.contains(&code.as_str()) {
all_accounts.push(code.as_str());
}
}
for account_code in all_accounts {
let prior_balance = prior
.balances
.get(account_code)
.map(|b| b.closing_balance)
.unwrap_or(Decimal::ZERO);
let current_balance = current
.balances
.get(account_code)
.map(|b| b.closing_balance)
.unwrap_or(Decimal::ZERO);
let description = current
.balances
.get(account_code)
.and_then(|b| b.account_description.clone())
.or_else(|| {
prior
.balances
.get(account_code)
.and_then(|b| b.account_description.clone())
});
if prior_balance != current_balance {
changes.push(BalanceChange::new(
account_code.to_string(),
description,
prior_balance,
current_balance,
significance_threshold,
));
}
}
changes
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_account_balance_debit_normal() {
let mut balance = AccountBalance::new(
"1000".to_string(),
"1100".to_string(),
AccountType::Asset,
"USD".to_string(),
2022,
6,
);
balance.set_opening_balance(dec!(10000));
balance.apply_debit(dec!(5000));
balance.apply_credit(dec!(2000));
assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
}
#[test]
fn test_account_balance_credit_normal() {
let mut balance = AccountBalance::new(
"1000".to_string(),
"2100".to_string(),
AccountType::Liability,
"USD".to_string(),
2022,
6,
);
balance.set_opening_balance(dec!(10000));
balance.apply_credit(dec!(5000));
balance.apply_debit(dec!(2000));
assert_eq!(balance.closing_balance, dec!(13000)); assert_eq!(balance.net_change(), dec!(3000));
}
#[test]
fn test_balance_snapshot_balanced() {
let mut snapshot = BalanceSnapshot::new(
"SNAP001".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
2022,
6,
"USD".to_string(),
);
let mut cash = AccountBalance::new(
"1000".to_string(),
"1100".to_string(),
AccountType::Asset,
"USD".to_string(),
2022,
6,
);
cash.closing_balance = dec!(50000);
snapshot.add_balance(cash);
let mut ap = AccountBalance::new(
"1000".to_string(),
"2100".to_string(),
AccountType::Liability,
"USD".to_string(),
2022,
6,
);
ap.closing_balance = dec!(20000);
snapshot.add_balance(ap);
let mut equity = AccountBalance::new(
"1000".to_string(),
"3100".to_string(),
AccountType::Equity,
"USD".to_string(),
2022,
6,
);
equity.closing_balance = dec!(30000);
snapshot.add_balance(equity);
assert!(snapshot.is_balanced);
assert_eq!(snapshot.total_assets, dec!(50000));
assert_eq!(snapshot.total_liabilities, dec!(20000));
assert_eq!(snapshot.total_equity, dec!(30000));
}
#[test]
fn test_balance_snapshot_with_income() {
let mut snapshot = BalanceSnapshot::new(
"SNAP001".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(),
2022,
6,
"USD".to_string(),
);
let mut cash = AccountBalance::new(
"1000".to_string(),
"1100".to_string(),
AccountType::Asset,
"USD".to_string(),
2022,
6,
);
cash.closing_balance = dec!(60000);
snapshot.add_balance(cash);
let mut ap = AccountBalance::new(
"1000".to_string(),
"2100".to_string(),
AccountType::Liability,
"USD".to_string(),
2022,
6,
);
ap.closing_balance = dec!(20000);
snapshot.add_balance(ap);
let mut equity = AccountBalance::new(
"1000".to_string(),
"3100".to_string(),
AccountType::Equity,
"USD".to_string(),
2022,
6,
);
equity.closing_balance = dec!(30000);
snapshot.add_balance(equity);
let mut revenue = AccountBalance::new(
"1000".to_string(),
"4100".to_string(),
AccountType::Revenue,
"USD".to_string(),
2022,
6,
);
revenue.closing_balance = dec!(50000);
snapshot.add_balance(revenue);
let mut expense = AccountBalance::new(
"1000".to_string(),
"5100".to_string(),
AccountType::Expense,
"USD".to_string(),
2022,
6,
);
expense.closing_balance = dec!(40000);
snapshot.add_balance(expense);
assert!(snapshot.is_balanced);
assert_eq!(snapshot.net_income, dec!(10000));
}
#[test]
fn test_account_type_from_code() {
assert_eq!(AccountType::from_account_code("1100"), AccountType::Asset);
assert_eq!(
AccountType::from_account_code("2100"),
AccountType::Liability
);
assert_eq!(AccountType::from_account_code("3100"), AccountType::Equity);
assert_eq!(AccountType::from_account_code("4100"), AccountType::Revenue);
assert_eq!(AccountType::from_account_code("5100"), AccountType::Expense);
}
#[test]
fn test_account_type_from_code_with_framework_us_gaap() {
assert_eq!(
AccountType::from_account_code_with_framework("1100", "us_gaap"),
AccountType::Asset
);
assert_eq!(
AccountType::from_account_code_with_framework("4000", "us_gaap"),
AccountType::Revenue
);
}
#[test]
fn test_account_type_from_code_with_framework_french_gaap() {
assert_eq!(
AccountType::from_account_code_with_framework("101000", "french_gaap"),
AccountType::Equity
);
assert_eq!(
AccountType::from_account_code_with_framework("210000", "french_gaap"),
AccountType::Asset
);
assert_eq!(
AccountType::from_account_code_with_framework("701000", "french_gaap"),
AccountType::Revenue
);
}
#[test]
fn test_account_type_from_code_with_framework_german_gaap() {
assert_eq!(
AccountType::from_account_code_with_framework("0200", "german_gaap"),
AccountType::Asset
);
assert_eq!(
AccountType::from_account_code_with_framework("2000", "german_gaap"),
AccountType::Equity
);
assert_eq!(
AccountType::from_account_code_with_framework("4000", "german_gaap"),
AccountType::Revenue
);
}
#[test]
fn test_balance_roll_forward() {
let mut balance = AccountBalance::new(
"1000".to_string(),
"1100".to_string(),
AccountType::Asset,
"USD".to_string(),
2022,
12,
);
balance.set_opening_balance(dec!(10000));
balance.apply_debit(dec!(5000));
balance.roll_forward();
assert_eq!(balance.opening_balance, dec!(15000));
assert_eq!(balance.period_debits, Decimal::ZERO);
assert_eq!(balance.fiscal_year, 2023);
assert_eq!(balance.fiscal_period, 1);
}
}