use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SubledgerDocumentStatus {
#[default]
Open,
PartiallyCleared,
Cleared,
Reversed,
OnHold,
InDispute,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClearingInfo {
pub clearing_document: String,
pub clearing_date: NaiveDate,
pub clearing_amount: Decimal,
pub clearing_type: ClearingType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClearingType {
Payment,
Memo,
WriteOff,
Netting,
Manual,
Reversal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GLReference {
pub journal_entry_id: String,
pub posting_date: NaiveDate,
pub gl_account: String,
pub amount: Decimal,
pub debit_credit: DebitCredit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DebitCredit {
Debit,
Credit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaxInfo {
pub tax_code: String,
pub tax_rate: Decimal,
pub tax_base: Decimal,
pub tax_amount: Decimal,
pub jurisdiction: Option<String>,
}
impl TaxInfo {
pub fn new(tax_code: String, tax_rate: Decimal, tax_base: Decimal) -> Self {
let tax_amount = (tax_base * tax_rate / dec!(100)).round_dp(2);
Self {
tax_code,
tax_rate,
tax_base,
tax_amount,
jurisdiction: None,
}
}
pub fn with_amount(
tax_code: String,
tax_rate: Decimal,
tax_base: Decimal,
tax_amount: Decimal,
) -> Self {
Self {
tax_code,
tax_rate,
tax_base,
tax_amount,
jurisdiction: None,
}
}
pub fn with_jurisdiction(mut self, jurisdiction: String) -> Self {
self.jurisdiction = Some(jurisdiction);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentTerms {
pub terms_code: String,
pub description: String,
pub net_due_days: u32,
pub discount_percent: Option<Decimal>,
pub discount_days: Option<u32>,
pub discount_percent_2: Option<Decimal>,
pub discount_days_2: Option<u32>,
}
impl PaymentTerms {
pub fn net(days: u32) -> Self {
Self {
terms_code: format!("NET{days}"),
description: format!("Net {days} days"),
net_due_days: days,
discount_percent: None,
discount_days: None,
discount_percent_2: None,
discount_days_2: None,
}
}
pub fn with_discount(net_days: u32, discount_percent: Decimal, discount_days: u32) -> Self {
Self {
terms_code: format!("{discount_percent}/{discount_days}NET{net_days}"),
description: format!(
"{discount_percent}% discount if paid within {discount_days} days, net {net_days} days"
),
net_due_days: net_days,
discount_percent: Some(discount_percent),
discount_days: Some(discount_days),
discount_percent_2: None,
discount_days_2: None,
}
}
pub fn net_30() -> Self {
Self::net(30)
}
pub fn net_60() -> Self {
Self::net(60)
}
pub fn net_90() -> Self {
Self::net(90)
}
pub fn two_ten_net_30() -> Self {
Self::with_discount(30, dec!(2), 10)
}
pub fn one_ten_net_30() -> Self {
Self::with_discount(30, dec!(1), 10)
}
pub fn calculate_due_date(&self, baseline_date: NaiveDate) -> NaiveDate {
baseline_date + chrono::Duration::days(self.net_due_days as i64)
}
pub fn calculate_discount_date(&self, baseline_date: NaiveDate) -> Option<NaiveDate> {
self.discount_days
.map(|days| baseline_date + chrono::Duration::days(days as i64))
}
pub fn calculate_discount(
&self,
base_amount: Decimal,
payment_date: NaiveDate,
baseline_date: NaiveDate,
) -> Decimal {
if let (Some(discount_percent), Some(discount_days)) =
(self.discount_percent, self.discount_days)
{
let discount_deadline = baseline_date + chrono::Duration::days(discount_days as i64);
if payment_date <= discount_deadline {
return (base_amount * discount_percent / dec!(100)).round_dp(2);
}
}
Decimal::ZERO
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconciliationStatus {
pub company_code: String,
pub gl_account: String,
pub subledger_type: SubledgerType,
pub as_of_date: NaiveDate,
pub gl_balance: Decimal,
pub subledger_balance: Decimal,
pub difference: Decimal,
pub is_reconciled: bool,
#[serde(with = "crate::serde_timestamp::utc")]
pub reconciled_at: DateTime<Utc>,
pub unreconciled_items: Vec<UnreconciledItem>,
}
impl ReconciliationStatus {
pub fn new(
company_code: String,
gl_account: String,
subledger_type: SubledgerType,
as_of_date: NaiveDate,
gl_balance: Decimal,
subledger_balance: Decimal,
tolerance: Decimal,
) -> Self {
let difference = gl_balance - subledger_balance;
let is_reconciled = difference.abs() <= tolerance;
Self {
company_code,
gl_account,
subledger_type,
as_of_date,
gl_balance,
subledger_balance,
difference,
is_reconciled,
reconciled_at: Utc::now(),
unreconciled_items: Vec::new(),
}
}
pub fn add_unreconciled_item(&mut self, item: UnreconciledItem) {
self.unreconciled_items.push(item);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SubledgerType {
AR,
AP,
FA,
Inventory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnreconciledItem {
pub document_number: String,
pub document_type: String,
pub subledger_amount: Decimal,
pub gl_amount: Decimal,
pub difference: Decimal,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyAmount {
pub document_amount: Decimal,
pub document_currency: String,
pub local_amount: Decimal,
pub local_currency: String,
pub exchange_rate: Decimal,
}
impl CurrencyAmount {
pub fn single_currency(amount: Decimal, currency: String) -> Self {
Self {
document_amount: amount,
document_currency: currency.clone(),
local_amount: amount,
local_currency: currency,
exchange_rate: Decimal::ONE,
}
}
pub fn with_conversion(
document_amount: Decimal,
document_currency: String,
local_currency: String,
exchange_rate: Decimal,
) -> Self {
let local_amount = (document_amount * exchange_rate).round_dp(2);
Self {
document_amount,
document_currency,
local_amount,
local_currency,
exchange_rate,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum BaselineDateType {
#[default]
DocumentDate,
PostingDate,
EntryDate,
GoodsReceiptDate,
CustomDate,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DunningInfo {
pub dunning_level: u8,
pub max_dunning_level: u8,
pub last_dunning_date: Option<NaiveDate>,
pub last_dunning_run: Option<String>,
pub dunning_blocked: bool,
pub block_reason: Option<String>,
}
impl DunningInfo {
pub fn advance_level(&mut self, dunning_date: NaiveDate, run_id: String) {
if !self.dunning_blocked {
self.dunning_level += 1;
if self.dunning_level > self.max_dunning_level {
self.max_dunning_level = self.dunning_level;
}
self.last_dunning_date = Some(dunning_date);
self.last_dunning_run = Some(run_id);
}
}
pub fn block(&mut self, reason: String) {
self.dunning_blocked = true;
self.block_reason = Some(reason);
}
pub fn unblock(&mut self) {
self.dunning_blocked = false;
self.block_reason = None;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_payment_terms_due_date() {
let terms = PaymentTerms::net_30();
let baseline = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let due_date = terms.calculate_due_date(baseline);
assert_eq!(due_date, NaiveDate::from_ymd_opt(2024, 2, 14).unwrap());
}
#[test]
fn test_payment_terms_discount() {
let terms = PaymentTerms::two_ten_net_30();
let baseline = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let amount = dec!(1000);
let early_payment = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
let discount = terms.calculate_discount(amount, early_payment, baseline);
assert_eq!(discount, dec!(20));
let late_payment = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
let no_discount = terms.calculate_discount(amount, late_payment, baseline);
assert_eq!(no_discount, Decimal::ZERO);
}
#[test]
fn test_tax_info() {
let tax = TaxInfo::new("VAT".to_string(), dec!(20), dec!(1000));
assert_eq!(tax.tax_amount, dec!(200));
}
#[test]
fn test_currency_conversion() {
let amount = CurrencyAmount::with_conversion(
dec!(1000),
"EUR".to_string(),
"USD".to_string(),
dec!(1.10),
);
assert_eq!(amount.local_amount, dec!(1100));
}
}