use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::CurrencyCode;
use std::fmt;
use std::str::FromStr;
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AccountType {
Asset,
Liability,
Equity,
Revenue,
Expense,
}
impl AccountType {
#[must_use]
pub const fn normal_balance(&self) -> BalanceSide {
match self {
Self::Asset | Self::Expense => BalanceSide::Debit,
Self::Liability | Self::Equity | Self::Revenue => BalanceSide::Credit,
}
}
#[must_use]
pub const fn is_balance_sheet(&self) -> bool {
matches!(self, Self::Asset | Self::Liability | Self::Equity)
}
#[must_use]
pub const fn is_income_statement(&self) -> bool {
matches!(self, Self::Revenue | Self::Expense)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BalanceSide {
#[default]
Debit,
Credit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AccountSubType {
Cash,
AccountsReceivable,
Inventory,
PrepaidExpense,
FixedAsset,
AccumulatedDepreciation,
OtherCurrentAsset,
OtherNonCurrentAsset,
AccountsPayable,
AccruedLiabilities,
UnearnedRevenue,
ShortTermDebt,
LongTermDebt,
OtherCurrentLiability,
OtherNonCurrentLiability,
CommonStock,
RetainedEarnings,
OtherEquity,
SalesRevenue,
ServiceRevenue,
OtherRevenue,
CostOfGoodsSold,
OperatingExpense,
Payroll,
RentExpense,
UtilitiesExpense,
DepreciationExpense,
InterestExpense,
TaxExpense,
OtherExpense,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AccountStatus {
#[default]
Active,
Inactive,
Archived,
}
impl fmt::Display for AccountStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Inactive => write!(f, "inactive"),
Self::Archived => write!(f, "archived"),
}
}
}
impl FromStr for AccountStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"active" => Ok(Self::Active),
"inactive" => Ok(Self::Inactive),
"archived" => Ok(Self::Archived),
_ => Err(format!("Unknown account status: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PeriodStatus {
#[default]
Future,
Open,
Closed,
Locked,
}
impl fmt::Display for PeriodStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Future => write!(f, "future"),
Self::Open => write!(f, "open"),
Self::Closed => write!(f, "closed"),
Self::Locked => write!(f, "locked"),
}
}
}
impl FromStr for PeriodStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"future" => Ok(Self::Future),
"open" => Ok(Self::Open),
"closed" => Ok(Self::Closed),
"locked" => Ok(Self::Locked),
_ => Err(format!("Unknown period status: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum JournalEntryType {
#[default]
Standard,
Adjusting,
Closing,
Reversing,
Opening,
}
impl fmt::Display for JournalEntryType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Standard => write!(f, "standard"),
Self::Adjusting => write!(f, "adjusting"),
Self::Closing => write!(f, "closing"),
Self::Reversing => write!(f, "reversing"),
Self::Opening => write!(f, "opening"),
}
}
}
impl FromStr for JournalEntryType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"standard" => Ok(Self::Standard),
"adjusting" => Ok(Self::Adjusting),
"closing" => Ok(Self::Closing),
"reversing" => Ok(Self::Reversing),
"opening" => Ok(Self::Opening),
_ => Err(format!("Unknown journal entry type: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum JournalEntrySource {
#[default]
Manual,
AutoInvoice,
AutoPayment,
AutoBill,
AutoBillPayment,
AutoInventory,
AutoWriteOff,
SystemClosing,
Import,
}
impl fmt::Display for JournalEntrySource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Manual => write!(f, "manual"),
Self::AutoInvoice => write!(f, "auto_invoice"),
Self::AutoPayment => write!(f, "auto_payment"),
Self::AutoBill => write!(f, "auto_bill"),
Self::AutoBillPayment => write!(f, "auto_bill_payment"),
Self::AutoInventory => write!(f, "auto_inventory"),
Self::AutoWriteOff => write!(f, "auto_write_off"),
Self::SystemClosing => write!(f, "system_closing"),
Self::Import => write!(f, "import"),
}
}
}
impl FromStr for JournalEntrySource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"manual" => Ok(Self::Manual),
"auto_invoice" => Ok(Self::AutoInvoice),
"auto_payment" => Ok(Self::AutoPayment),
"auto_bill" => Ok(Self::AutoBill),
"auto_bill_payment" => Ok(Self::AutoBillPayment),
"auto_inventory" => Ok(Self::AutoInventory),
"auto_write_off" => Ok(Self::AutoWriteOff),
"system_closing" => Ok(Self::SystemClosing),
"import" => Ok(Self::Import),
_ => Err(format!("Unknown journal entry source: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum JournalEntryStatus {
#[default]
Draft,
Pending,
Posted,
Voided,
Reversed,
}
impl fmt::Display for JournalEntryStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Draft => write!(f, "draft"),
Self::Pending => write!(f, "pending"),
Self::Posted => write!(f, "posted"),
Self::Voided => write!(f, "voided"),
Self::Reversed => write!(f, "reversed"),
}
}
}
impl FromStr for JournalEntryStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"pending" => Ok(Self::Pending),
"posted" => Ok(Self::Posted),
"voided" => Ok(Self::Voided),
"reversed" => Ok(Self::Reversed),
_ => Err(format!("Unknown journal entry status: {s}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlAccount {
pub id: Uuid,
pub account_number: String,
pub name: String,
pub description: Option<String>,
pub account_type: AccountType,
pub account_sub_type: Option<AccountSubType>,
pub parent_account_id: Option<Uuid>,
pub is_header: bool,
pub is_posting: bool,
pub normal_balance: BalanceSide,
pub currency: CurrencyCode,
pub status: AccountStatus,
pub current_balance: Decimal,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl GlAccount {
#[must_use]
pub fn can_post(&self) -> bool {
self.is_posting && self.status == AccountStatus::Active
}
#[must_use]
pub fn balance_effect(&self, debit: Decimal, credit: Decimal) -> Decimal {
match self.account_type.normal_balance() {
BalanceSide::Debit => debit - credit,
BalanceSide::Credit => credit - debit,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlPeriod {
pub id: Uuid,
pub period_name: String,
pub fiscal_year: i32,
pub period_number: i32,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub status: PeriodStatus,
pub closed_at: Option<DateTime<Utc>>,
pub closed_by: Option<String>,
pub locked_at: Option<DateTime<Utc>>,
pub locked_by: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl GlPeriod {
#[must_use]
pub fn can_post(&self) -> bool {
self.status == PeriodStatus::Open
}
#[must_use]
pub fn contains_date(&self, date: NaiveDate) -> bool {
date >= self.start_date && date <= self.end_date
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntry {
pub id: Uuid,
pub entry_number: String,
pub entry_date: NaiveDate,
pub period_id: Uuid,
pub entry_type: JournalEntryType,
pub source: JournalEntrySource,
pub source_document_type: Option<String>,
pub source_document_id: Option<Uuid>,
pub description: String,
pub total_debits: Decimal,
pub total_credits: Decimal,
pub is_balanced: bool,
pub status: JournalEntryStatus,
pub posted_at: Option<DateTime<Utc>>,
pub posted_by: Option<String>,
pub reversed_entry_id: Option<Uuid>,
pub reversing_entry_id: Option<Uuid>,
pub lines: Vec<JournalEntryLine>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl JournalEntry {
#[must_use]
pub fn is_balanced(&self) -> bool {
self.total_debits == self.total_credits
}
pub fn recalculate_totals(&mut self) {
self.total_debits = self.lines.iter().map(|l| l.debit_amount).sum();
self.total_credits = self.lines.iter().map(|l| l.credit_amount).sum();
self.is_balanced = self.total_debits == self.total_credits;
}
pub fn can_post(&self) -> bool {
if self.status != JournalEntryStatus::Draft || self.lines.is_empty() {
return false;
}
if !self.lines.iter().all(JournalEntryLine::is_valid) {
return false;
}
let calculated_debits: Decimal = self.lines.iter().map(|l| l.debit_amount).sum();
let calculated_credits: Decimal = self.lines.iter().map(|l| l.credit_amount).sum();
self.total_debits == calculated_debits
&& self.total_credits == calculated_credits
&& calculated_debits == calculated_credits
}
#[must_use]
pub fn can_void(&self) -> bool {
self.status == JournalEntryStatus::Posted
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntryLine {
pub id: Uuid,
pub journal_entry_id: Uuid,
pub line_number: i32,
pub account_id: Uuid,
pub account_number: Option<String>,
pub account_name: Option<String>,
pub description: Option<String>,
pub debit_amount: Decimal,
pub credit_amount: Decimal,
pub currency: CurrencyCode,
pub reference_type: Option<String>,
pub reference_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
impl JournalEntryLine {
#[must_use]
pub fn is_valid(&self) -> bool {
(self.debit_amount > Decimal::ZERO && self.credit_amount == Decimal::ZERO)
|| (self.debit_amount == Decimal::ZERO && self.credit_amount > Decimal::ZERO)
}
#[must_use]
pub fn net_amount(&self) -> Decimal {
self.debit_amount - self.credit_amount
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoPostingConfig {
pub id: Uuid,
pub config_name: String,
pub cash_account_id: Uuid,
pub accounts_receivable_account_id: Uuid,
pub inventory_account_id: Uuid,
pub accounts_payable_account_id: Uuid,
pub unearned_revenue_account_id: Option<Uuid>,
pub sales_revenue_account_id: Uuid,
pub shipping_revenue_account_id: Option<Uuid>,
pub cogs_account_id: Uuid,
pub bad_debt_expense_account_id: Option<Uuid>,
#[serde(default)]
pub fx_gain_loss_account_id: Option<Uuid>,
#[serde(default)]
pub auto_post_depreciation: bool,
#[serde(default)]
pub auto_post_revenue_recognition: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountBalance {
pub id: Uuid,
pub account_id: Uuid,
pub period_id: Uuid,
pub opening_balance: Decimal,
pub total_debits: Decimal,
pub total_credits: Decimal,
pub closing_balance: Decimal,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrialBalanceLine {
pub account_id: Uuid,
pub account_number: String,
pub account_name: String,
pub account_type: AccountType,
pub debit_balance: Decimal,
pub credit_balance: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrialBalance {
pub as_of_date: NaiveDate,
pub period_id: Option<Uuid>,
pub total_debits: Decimal,
pub total_credits: Decimal,
pub is_balanced: bool,
pub lines: Vec<TrialBalanceLine>,
}
impl TrialBalance {
#[must_use]
pub fn is_balanced(&self) -> bool {
self.total_debits == self.total_credits
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceSheetLine {
pub account_id: Uuid,
pub account_number: String,
pub account_name: String,
pub account_sub_type: Option<AccountSubType>,
pub balance: Decimal,
pub indent_level: i32,
pub is_total: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceSheet {
pub as_of_date: NaiveDate,
pub total_assets: Decimal,
pub total_liabilities: Decimal,
pub total_equity: Decimal,
pub assets: Vec<BalanceSheetLine>,
pub liabilities: Vec<BalanceSheetLine>,
pub equity: Vec<BalanceSheetLine>,
}
impl BalanceSheet {
#[must_use]
pub fn is_balanced(&self) -> bool {
self.total_assets == self.total_liabilities + self.total_equity
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomeStatementLine {
pub account_id: Uuid,
pub account_number: String,
pub account_name: String,
pub account_sub_type: Option<AccountSubType>,
pub amount: Decimal,
pub indent_level: i32,
pub is_total: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomeStatement {
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub total_revenue: Decimal,
pub total_expenses: Decimal,
pub net_income: Decimal,
pub revenue_lines: Vec<IncomeStatementLine>,
pub expense_lines: Vec<IncomeStatementLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGlAccount {
pub account_number: String,
pub name: String,
pub description: Option<String>,
pub account_type: AccountType,
pub account_sub_type: Option<AccountSubType>,
pub parent_account_id: Option<Uuid>,
pub is_header: Option<bool>,
pub is_posting: Option<bool>,
pub currency: Option<CurrencyCode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateGlAccount {
pub name: Option<String>,
pub description: Option<String>,
pub parent_account_id: Option<Uuid>,
pub status: Option<AccountStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGlPeriod {
pub period_name: String,
pub fiscal_year: i32,
pub period_number: i32,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateJournalEntry {
pub entry_date: NaiveDate,
pub entry_type: Option<JournalEntryType>,
pub description: String,
pub lines: Vec<CreateJournalEntryLine>,
pub source_document_type: Option<String>,
pub source_document_id: Option<Uuid>,
pub auto_post: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateJournalEntryLine {
pub account_id: Uuid,
pub description: Option<String>,
pub debit_amount: Decimal,
pub credit_amount: Decimal,
pub reference_type: Option<String>,
pub reference_id: Option<Uuid>,
}
impl CreateJournalEntryLine {
#[must_use]
pub const fn debit(account_id: Uuid, amount: Decimal, description: Option<String>) -> Self {
Self {
account_id,
description,
debit_amount: amount,
credit_amount: Decimal::ZERO,
reference_type: None,
reference_id: None,
}
}
#[must_use]
pub const fn credit(account_id: Uuid, amount: Decimal, description: Option<String>) -> Self {
Self {
account_id,
description,
debit_amount: Decimal::ZERO,
credit_amount: amount,
reference_type: None,
reference_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAutoPostingConfig {
pub config_name: String,
pub cash_account_id: Uuid,
pub accounts_receivable_account_id: Uuid,
pub inventory_account_id: Uuid,
pub accounts_payable_account_id: Uuid,
pub unearned_revenue_account_id: Option<Uuid>,
pub sales_revenue_account_id: Uuid,
pub shipping_revenue_account_id: Option<Uuid>,
pub cogs_account_id: Uuid,
pub bad_debt_expense_account_id: Option<Uuid>,
#[serde(default)]
pub fx_gain_loss_account_id: Option<Uuid>,
#[serde(default)]
pub auto_post_depreciation: bool,
#[serde(default)]
pub auto_post_revenue_recognition: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlAccountFilter {
pub account_type: Option<AccountType>,
pub account_sub_type: Option<AccountSubType>,
pub parent_account_id: Option<Uuid>,
pub status: Option<AccountStatus>,
pub is_posting: Option<bool>,
pub is_header: Option<bool>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlPeriodFilter {
pub fiscal_year: Option<i32>,
pub status: Option<PeriodStatus>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct JournalEntryFilter {
pub period_id: Option<Uuid>,
pub entry_type: Option<JournalEntryType>,
pub source: Option<JournalEntrySource>,
pub status: Option<JournalEntryStatus>,
pub account_id: Option<Uuid>,
pub from_date: Option<NaiveDate>,
pub to_date: Option<NaiveDate>,
pub source_document_type: Option<String>,
pub source_document_id: Option<Uuid>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub const FX_REVALUATION_REFERENCE: &str = "fx_revaluation";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevaluationLine {
pub account_id: Uuid,
pub account_number: String,
pub account_name: String,
pub currency: CurrencyCode,
pub normal_balance: BalanceSide,
pub foreign_balance: Decimal,
pub carrying_value: Decimal,
pub rate: Decimal,
pub revalued_value: Decimal,
pub adjustment: Decimal,
pub unrealized_gain_loss: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevaluationResult {
pub as_of_date: NaiveDate,
pub base_currency: CurrencyCode,
pub total_unrealized_gain_loss: Decimal,
pub lines: Vec<RevaluationLine>,
pub journal_entry: Option<JournalEntry>,
}
#[must_use]
pub fn compute_revaluation_line(
account: &GlAccount,
foreign_balance: Decimal,
rate: Decimal,
base_decimal_places: u32,
) -> RevaluationLine {
let normal_balance = account.account_type.normal_balance();
let revalued_value = (foreign_balance * rate).round_dp(base_decimal_places);
let adjustment = revalued_value - account.current_balance;
let unrealized_gain_loss = match normal_balance {
BalanceSide::Credit => -adjustment,
_ => adjustment,
};
RevaluationLine {
account_id: account.id,
account_number: account.account_number.clone(),
account_name: account.name.clone(),
currency: account.currency,
normal_balance,
foreign_balance,
carrying_value: account.current_balance,
rate,
revalued_value,
adjustment,
unrealized_gain_loss,
}
}
#[must_use]
pub fn build_revaluation_journal_lines(
lines: &[RevaluationLine],
fx_account_id: Uuid,
) -> Vec<CreateJournalEntryLine> {
let mut entry_lines = Vec::new();
let mut fx_net = Decimal::ZERO;
for line in lines {
if line.adjustment.is_zero() {
continue;
}
let amount = line.adjustment.abs();
let increase = line.adjustment > Decimal::ZERO;
let debit_side = match line.normal_balance {
BalanceSide::Credit => !increase,
_ => increase,
};
let (debit_amount, credit_amount) = if debit_side {
fx_net += amount;
(amount, Decimal::ZERO)
} else {
fx_net -= amount;
(Decimal::ZERO, amount)
};
entry_lines.push(CreateJournalEntryLine {
account_id: line.account_id,
description: Some(format!(
"FX revaluation of {} ({})",
line.account_number, line.currency
)),
debit_amount,
credit_amount,
reference_type: Some(FX_REVALUATION_REFERENCE.to_string()),
reference_id: Some(line.account_id),
});
}
if !fx_net.is_zero() {
let amount = fx_net.abs();
let (debit_amount, credit_amount) =
if fx_net > Decimal::ZERO { (Decimal::ZERO, amount) } else { (amount, Decimal::ZERO) };
entry_lines.push(CreateJournalEntryLine {
account_id: fx_account_id,
description: Some("Unrealized FX gain/loss".to_string()),
debit_amount,
credit_amount,
reference_type: Some(FX_REVALUATION_REFERENCE.to_string()),
reference_id: None,
});
}
entry_lines
}
#[must_use]
pub fn generate_journal_entry_number() -> String {
let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
let suffix = Uuid::new_v4().simple().to_string();
format!("JE-{}-{}", timestamp, &suffix[..8])
}
#[must_use]
pub fn generate_period_name(year: i32, month: i32) -> String {
format!("{year}-{month:02}")
}
#[must_use]
pub fn create_default_chart_of_accounts() -> Vec<CreateGlAccount> {
vec![
CreateGlAccount {
account_number: "1000".into(),
name: "Assets".into(),
description: Some("All asset accounts".into()),
account_type: AccountType::Asset,
account_sub_type: None,
parent_account_id: None,
is_header: Some(true),
is_posting: Some(false),
currency: None,
},
CreateGlAccount {
account_number: "1010".into(),
name: "Cash".into(),
description: Some("Cash and cash equivalents".into()),
account_type: AccountType::Asset,
account_sub_type: Some(AccountSubType::Cash),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "1100".into(),
name: "Accounts Receivable".into(),
description: Some("Customer receivables".into()),
account_type: AccountType::Asset,
account_sub_type: Some(AccountSubType::AccountsReceivable),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "1200".into(),
name: "Inventory".into(),
description: Some("Merchandise inventory".into()),
account_type: AccountType::Asset,
account_sub_type: Some(AccountSubType::Inventory),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "2000".into(),
name: "Liabilities".into(),
description: Some("All liability accounts".into()),
account_type: AccountType::Liability,
account_sub_type: None,
parent_account_id: None,
is_header: Some(true),
is_posting: Some(false),
currency: None,
},
CreateGlAccount {
account_number: "2010".into(),
name: "Accounts Payable".into(),
description: Some("Supplier payables".into()),
account_type: AccountType::Liability,
account_sub_type: Some(AccountSubType::AccountsPayable),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "3000".into(),
name: "Equity".into(),
description: Some("Owner's equity accounts".into()),
account_type: AccountType::Equity,
account_sub_type: None,
parent_account_id: None,
is_header: Some(true),
is_posting: Some(false),
currency: None,
},
CreateGlAccount {
account_number: "3100".into(),
name: "Retained Earnings".into(),
description: Some("Accumulated profits".into()),
account_type: AccountType::Equity,
account_sub_type: Some(AccountSubType::RetainedEarnings),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "4000".into(),
name: "Revenue".into(),
description: Some("All revenue accounts".into()),
account_type: AccountType::Revenue,
account_sub_type: None,
parent_account_id: None,
is_header: Some(true),
is_posting: Some(false),
currency: None,
},
CreateGlAccount {
account_number: "4010".into(),
name: "Sales Revenue".into(),
description: Some("Product sales".into()),
account_type: AccountType::Revenue,
account_sub_type: Some(AccountSubType::SalesRevenue),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "5000".into(),
name: "Expenses".into(),
description: Some("All expense accounts".into()),
account_type: AccountType::Expense,
account_sub_type: None,
parent_account_id: None,
is_header: Some(true),
is_posting: Some(false),
currency: None,
},
CreateGlAccount {
account_number: "5010".into(),
name: "Cost of Goods Sold".into(),
description: Some("Direct cost of products sold".into()),
account_type: AccountType::Expense,
account_sub_type: Some(AccountSubType::CostOfGoodsSold),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
CreateGlAccount {
account_number: "5900".into(),
name: "Bad Debt Expense".into(),
description: Some("Uncollectible accounts written off".into()),
account_type: AccountType::Expense,
account_sub_type: Some(AccountSubType::OtherExpense),
parent_account_id: None,
is_header: Some(false),
is_posting: Some(true),
currency: None,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn foreign_account(account_type: AccountType, carrying: Decimal) -> GlAccount {
let now = Utc::now();
GlAccount {
id: Uuid::new_v4(),
account_number: "1015".into(),
name: "EUR Cash".into(),
description: None,
account_type,
account_sub_type: None,
parent_account_id: None,
is_header: false,
is_posting: true,
normal_balance: account_type.normal_balance(),
currency: CurrencyCode::EUR,
status: AccountStatus::Active,
current_balance: carrying,
created_at: now,
updated_at: now,
}
}
#[test]
fn revaluation_gain_on_debit_normal_account() {
let account = foreign_account(AccountType::Asset, dec!(1000));
let line = compute_revaluation_line(&account, dec!(1000), dec!(1.10), 2);
assert_eq!(line.revalued_value, dec!(1100.00));
assert_eq!(line.adjustment, dec!(100.00));
assert_eq!(line.unrealized_gain_loss, dec!(100.00));
}
#[test]
fn revaluation_loss_on_debit_normal_account() {
let account = foreign_account(AccountType::Asset, dec!(1000));
let line = compute_revaluation_line(&account, dec!(1000), dec!(0.85), 2);
assert_eq!(line.adjustment, dec!(-150.00));
assert_eq!(line.unrealized_gain_loss, dec!(-150.00));
}
#[test]
fn revaluation_on_credit_normal_account_flips_sign() {
let account = foreign_account(AccountType::Liability, dec!(500));
let line = compute_revaluation_line(&account, dec!(500), dec!(1.20), 2);
assert_eq!(line.adjustment, dec!(100.00));
assert_eq!(line.unrealized_gain_loss, dec!(-100.00));
}
#[test]
fn revaluation_noop_when_rate_unchanged() {
let account = foreign_account(AccountType::Asset, dec!(1000));
let line = compute_revaluation_line(&account, dec!(1000), dec!(1), 2);
assert!(line.adjustment.is_zero());
assert!(line.unrealized_gain_loss.is_zero());
assert!(build_revaluation_journal_lines(&[line], Uuid::new_v4()).is_empty());
}
#[test]
fn revaluation_rounds_to_base_precision() {
let account = foreign_account(AccountType::Asset, dec!(0));
let line = compute_revaluation_line(&account, dec!(100.333), dec!(1.005), 2);
assert_eq!(line.revalued_value, dec!(100.83));
}
#[test]
fn revaluation_journal_lines_are_balanced() {
let asset = foreign_account(AccountType::Asset, dec!(1000));
let liability = foreign_account(AccountType::Liability, dec!(500));
let fx_account = Uuid::new_v4();
let lines = vec![
compute_revaluation_line(&asset, dec!(1000), dec!(1.10), 2), compute_revaluation_line(&liability, dec!(500), dec!(1.20), 2), ];
let je_lines = build_revaluation_journal_lines(&lines, fx_account);
assert_eq!(je_lines.len(), 2);
let debits: Decimal = je_lines.iter().map(|l| l.debit_amount).sum();
let credits: Decimal = je_lines.iter().map(|l| l.credit_amount).sum();
assert_eq!(debits, credits);
assert!(je_lines.iter().all(|l| l.reference_type.as_deref() == Some("fx_revaluation")));
}
#[test]
fn revaluation_journal_lines_offset_net_gain_to_fx_account() {
let asset = foreign_account(AccountType::Asset, dec!(1000));
let fx_account = Uuid::new_v4();
let lines = vec![compute_revaluation_line(&asset, dec!(1000), dec!(1.10), 2)];
let je_lines = build_revaluation_journal_lines(&lines, fx_account);
assert_eq!(je_lines.len(), 2);
assert_eq!(je_lines[0].debit_amount, dec!(100.00));
assert_eq!(je_lines[1].account_id, fx_account);
assert_eq!(je_lines[1].credit_amount, dec!(100.00));
}
#[test]
fn revaluation_journal_lines_offset_net_loss_to_fx_account() {
let liability = foreign_account(AccountType::Liability, dec!(500));
let fx_account = Uuid::new_v4();
let lines = vec![compute_revaluation_line(&liability, dec!(500), dec!(1.20), 2)];
let je_lines = build_revaluation_journal_lines(&lines, fx_account);
assert_eq!(je_lines.len(), 2);
assert_eq!(je_lines[0].credit_amount, dec!(100.00));
assert_eq!(je_lines[1].account_id, fx_account);
assert_eq!(je_lines[1].debit_amount, dec!(100.00));
}
}