use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::models::subledger::{CurrencyAmount, GLReference, SubledgerDocumentStatus, TaxInfo};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ARCreditMemo {
pub credit_memo_number: String,
pub company_code: String,
pub customer_id: String,
pub customer_name: String,
pub memo_date: NaiveDate,
pub posting_date: NaiveDate,
pub memo_type: ARCreditMemoType,
pub status: SubledgerDocumentStatus,
pub reason_code: CreditMemoReason,
pub reason_description: String,
pub lines: Vec<ARCreditMemoLine>,
pub net_amount: CurrencyAmount,
pub tax_amount: CurrencyAmount,
pub gross_amount: CurrencyAmount,
pub amount_applied: Decimal,
pub amount_remaining: Decimal,
pub tax_details: Vec<TaxInfo>,
pub reference_invoice: Option<String>,
pub reference_return: Option<String>,
pub applied_invoices: Vec<CreditMemoApplication>,
pub gl_reference: Option<GLReference>,
pub approval_status: ApprovalStatus,
pub approved_by: Option<String>,
pub approved_date: Option<NaiveDate>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
pub notes: Option<String>,
}
impl ARCreditMemo {
#[allow(clippy::too_many_arguments)]
pub fn new(
credit_memo_number: String,
company_code: String,
customer_id: String,
customer_name: String,
memo_date: NaiveDate,
reason_code: CreditMemoReason,
reason_description: String,
currency: String,
) -> Self {
Self {
credit_memo_number,
company_code,
customer_id,
customer_name,
memo_date,
posting_date: memo_date,
memo_type: ARCreditMemoType::Standard,
status: SubledgerDocumentStatus::Open,
reason_code,
reason_description,
lines: Vec::new(),
net_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
tax_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
gross_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency),
amount_applied: Decimal::ZERO,
amount_remaining: Decimal::ZERO,
tax_details: Vec::new(),
reference_invoice: None,
reference_return: None,
applied_invoices: Vec::new(),
gl_reference: None,
approval_status: ApprovalStatus::Pending,
approved_by: None,
approved_date: None,
created_at: Utc::now(),
created_by: None,
notes: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn for_invoice(
credit_memo_number: String,
company_code: String,
customer_id: String,
customer_name: String,
memo_date: NaiveDate,
invoice_number: String,
reason_code: CreditMemoReason,
reason_description: String,
currency: String,
) -> Self {
let mut memo = Self::new(
credit_memo_number,
company_code,
customer_id,
customer_name,
memo_date,
reason_code,
reason_description,
currency,
);
memo.reference_invoice = Some(invoice_number);
memo
}
pub fn add_line(&mut self, line: ARCreditMemoLine) {
self.lines.push(line);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
let net_total: Decimal = self.lines.iter().map(|l| l.net_amount).sum();
let tax_total: Decimal = self.lines.iter().map(|l| l.tax_amount).sum();
let gross_total = net_total + tax_total;
self.net_amount.document_amount = net_total;
self.net_amount.local_amount = net_total * self.net_amount.exchange_rate;
self.tax_amount.document_amount = tax_total;
self.tax_amount.local_amount = tax_total * self.tax_amount.exchange_rate;
self.gross_amount.document_amount = gross_total;
self.gross_amount.local_amount = gross_total * self.gross_amount.exchange_rate;
self.amount_remaining = gross_total - self.amount_applied;
}
pub fn apply_to_invoice(&mut self, invoice_number: String, amount: Decimal) {
let application = CreditMemoApplication {
invoice_number,
amount_applied: amount,
application_date: chrono::Local::now().date_naive(),
};
self.applied_invoices.push(application);
self.amount_applied += amount;
self.amount_remaining = self.gross_amount.document_amount - self.amount_applied;
if self.amount_remaining <= Decimal::ZERO {
self.status = SubledgerDocumentStatus::Cleared;
} else {
self.status = SubledgerDocumentStatus::PartiallyCleared;
}
}
pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
self.approval_status = ApprovalStatus::Approved;
self.approved_by = Some(approver);
self.approved_date = Some(approval_date);
}
pub fn reject(&mut self, reason: String) {
self.approval_status = ApprovalStatus::Rejected;
self.notes = Some(format!(
"{}Rejected: {}",
self.notes
.as_ref()
.map(|n| format!("{n}. "))
.unwrap_or_default(),
reason
));
}
pub fn set_gl_reference(&mut self, reference: GLReference) {
self.gl_reference = Some(reference);
}
pub fn with_return_order(mut self, return_order: String) -> Self {
self.reference_return = Some(return_order);
self.memo_type = ARCreditMemoType::Return;
self
}
pub fn requires_approval(&self, threshold: Decimal) -> bool {
self.gross_amount.document_amount > threshold
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ARCreditMemoType {
#[default]
Standard,
Return,
PriceAdjustment,
QuantityAdjustment,
Rebate,
Promotional,
Cancellation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CreditMemoReason {
Return,
Damaged,
WrongItem,
PriceError,
QuantityError,
QualityIssue,
LateDelivery,
Promotional,
VolumeRebate,
Goodwill,
BillingError,
ContractAdjustment,
#[default]
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ARCreditMemoLine {
pub line_number: u32,
pub material_id: Option<String>,
pub description: String,
pub quantity: Decimal,
pub unit: String,
pub unit_price: Decimal,
pub net_amount: Decimal,
pub tax_code: Option<String>,
pub tax_rate: Decimal,
pub tax_amount: Decimal,
pub gross_amount: Decimal,
pub revenue_account: String,
pub reference_invoice_line: Option<u32>,
pub cost_center: Option<String>,
pub profit_center: Option<String>,
}
impl ARCreditMemoLine {
pub fn new(
line_number: u32,
description: String,
quantity: Decimal,
unit: String,
unit_price: Decimal,
revenue_account: String,
) -> Self {
let net_amount = (quantity * unit_price).round_dp(2);
Self {
line_number,
material_id: None,
description,
quantity,
unit,
unit_price,
net_amount,
tax_code: None,
tax_rate: Decimal::ZERO,
tax_amount: Decimal::ZERO,
gross_amount: net_amount,
revenue_account,
reference_invoice_line: None,
cost_center: None,
profit_center: None,
}
}
pub fn with_tax(mut self, tax_code: String, tax_rate: Decimal) -> Self {
self.tax_code = Some(tax_code);
self.tax_rate = tax_rate;
self.tax_amount = (self.net_amount * tax_rate / rust_decimal_macros::dec!(100)).round_dp(2);
self.gross_amount = self.net_amount + self.tax_amount;
self
}
pub fn with_invoice_reference(mut self, line_number: u32) -> Self {
self.reference_invoice_line = Some(line_number);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditMemoApplication {
pub invoice_number: String,
pub amount_applied: Decimal,
pub application_date: NaiveDate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ApprovalStatus {
#[default]
Pending,
Approved,
Rejected,
NotRequired,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_credit_memo_creation() {
let memo = ARCreditMemo::new(
"CM001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"Test Customer".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
CreditMemoReason::Return,
"Goods returned".to_string(),
"USD".to_string(),
);
assert_eq!(memo.status, SubledgerDocumentStatus::Open);
assert_eq!(memo.approval_status, ApprovalStatus::Pending);
}
#[test]
fn test_credit_memo_totals() {
let mut memo = ARCreditMemo::new(
"CM001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"Test Customer".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
CreditMemoReason::PriceError,
"Price correction".to_string(),
"USD".to_string(),
);
let line = ARCreditMemoLine::new(
1,
"Product A".to_string(),
dec!(5),
"EA".to_string(),
dec!(100),
"4000".to_string(),
)
.with_tax("VAT".to_string(), dec!(20));
memo.add_line(line);
assert_eq!(memo.net_amount.document_amount, dec!(500));
assert_eq!(memo.tax_amount.document_amount, dec!(100));
assert_eq!(memo.gross_amount.document_amount, dec!(600));
}
#[test]
fn test_apply_to_invoice() {
let mut memo = ARCreditMemo::new(
"CM001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"Test Customer".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
CreditMemoReason::Return,
"Goods returned".to_string(),
"USD".to_string(),
);
let line = ARCreditMemoLine::new(
1,
"Product A".to_string(),
dec!(10),
"EA".to_string(),
dec!(50),
"4000".to_string(),
);
memo.add_line(line);
memo.apply_to_invoice("INV001".to_string(), dec!(300));
assert_eq!(memo.amount_applied, dec!(300));
assert_eq!(memo.amount_remaining, dec!(200));
assert_eq!(memo.status, SubledgerDocumentStatus::PartiallyCleared);
}
#[test]
fn test_approval_workflow() {
let mut memo = ARCreditMemo::new(
"CM001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"Test Customer".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
CreditMemoReason::Return,
"Goods returned".to_string(),
"USD".to_string(),
);
memo.approve(
"MANAGER1".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 16).unwrap(),
);
assert_eq!(memo.approval_status, ApprovalStatus::Approved);
assert_eq!(memo.approved_by, Some("MANAGER1".to_string()));
}
}