#![allow(clippy::too_many_arguments)]
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentCorrection {
pub correction_id: String,
pub company_code: String,
pub customer_id: String,
pub original_payment_id: String,
pub correction_type: PaymentCorrectionType,
pub original_amount: Decimal,
pub correction_amount: Decimal,
pub currency: String,
pub correction_date: NaiveDate,
pub reversal_je_id: Option<String>,
pub correcting_payment_id: Option<String>,
pub affected_invoice_ids: Vec<String>,
pub status: CorrectionStatus,
pub reason: Option<String>,
pub bank_reference: Option<String>,
pub chargeback_code: Option<String>,
pub fee_amount: Decimal,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub resolved_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
impl PaymentCorrection {
pub fn new(
correction_id: String,
company_code: String,
customer_id: String,
original_payment_id: String,
correction_type: PaymentCorrectionType,
original_amount: Decimal,
correction_amount: Decimal,
currency: String,
correction_date: NaiveDate,
) -> Self {
Self {
correction_id,
company_code,
customer_id,
original_payment_id,
correction_type,
original_amount,
correction_amount,
currency,
correction_date,
reversal_je_id: None,
correcting_payment_id: None,
affected_invoice_ids: Vec::new(),
status: CorrectionStatus::Pending,
reason: None,
bank_reference: None,
chargeback_code: None,
fee_amount: Decimal::ZERO,
created_at: Utc::now(),
created_by: None,
resolved_at: None,
notes: None,
}
}
pub fn nsf(
correction_id: String,
company_code: String,
customer_id: String,
original_payment_id: String,
original_amount: Decimal,
currency: String,
correction_date: NaiveDate,
bank_reference: String,
nsf_fee: Decimal,
) -> Self {
let mut correction = Self::new(
correction_id,
company_code,
customer_id,
original_payment_id,
PaymentCorrectionType::NSF,
original_amount,
original_amount, currency,
correction_date,
);
correction.bank_reference = Some(bank_reference);
correction.fee_amount = nsf_fee;
correction.reason = Some("Payment returned - Non-Sufficient Funds".to_string());
correction
}
pub fn chargeback(
correction_id: String,
company_code: String,
customer_id: String,
original_payment_id: String,
chargeback_amount: Decimal,
currency: String,
correction_date: NaiveDate,
chargeback_code: String,
reason: String,
) -> Self {
let mut correction = Self::new(
correction_id,
company_code,
customer_id,
original_payment_id,
PaymentCorrectionType::Chargeback,
chargeback_amount,
chargeback_amount,
currency,
correction_date,
);
correction.chargeback_code = Some(chargeback_code);
correction.reason = Some(reason);
correction
}
pub fn with_reversal_je(mut self, je_id: String) -> Self {
self.reversal_je_id = Some(je_id);
self
}
pub fn add_affected_invoice(&mut self, invoice_id: String) {
self.affected_invoice_ids.push(invoice_id);
}
pub fn process(&mut self, reversal_je_id: Option<String>) {
self.status = CorrectionStatus::Processed;
self.reversal_je_id = reversal_je_id;
}
pub fn resolve(&mut self, correcting_payment_id: Option<String>) {
self.status = CorrectionStatus::Resolved;
self.correcting_payment_id = correcting_payment_id;
self.resolved_at = Some(Utc::now());
}
pub fn write_off(&mut self) {
self.status = CorrectionStatus::WrittenOff;
self.resolved_at = Some(Utc::now());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaymentCorrectionType {
NSF,
Chargeback,
WrongAmount,
WrongCustomer,
DuplicatePayment,
CustomerReversal,
BankError,
SystemError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CorrectionStatus {
#[default]
Pending,
Processed,
Resolved,
WrittenOff,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShortPayment {
pub short_payment_id: String,
pub company_code: String,
pub customer_id: String,
pub payment_id: String,
pub invoice_id: String,
pub expected_amount: Decimal,
pub paid_amount: Decimal,
pub short_amount: Decimal,
pub currency: String,
pub payment_date: NaiveDate,
pub reason_code: ShortPaymentReasonCode,
pub reason_description: Option<String>,
pub disposition: ShortPaymentDisposition,
pub credit_memo_id: Option<String>,
pub write_off_je_id: Option<String>,
pub rebill_invoice_id: Option<String>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub resolved_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
impl ShortPayment {
pub fn new(
short_payment_id: String,
company_code: String,
customer_id: String,
payment_id: String,
invoice_id: String,
expected_amount: Decimal,
paid_amount: Decimal,
currency: String,
payment_date: NaiveDate,
reason_code: ShortPaymentReasonCode,
) -> Self {
Self {
short_payment_id,
company_code,
customer_id,
payment_id,
invoice_id,
expected_amount,
paid_amount,
short_amount: expected_amount - paid_amount,
currency,
payment_date,
reason_code,
reason_description: None,
disposition: ShortPaymentDisposition::Pending,
credit_memo_id: None,
write_off_je_id: None,
rebill_invoice_id: None,
created_at: Utc::now(),
created_by: None,
resolved_at: None,
notes: None,
}
}
pub fn with_reason(mut self, description: String) -> Self {
self.reason_description = Some(description);
self
}
pub fn issue_credit_memo(&mut self, credit_memo_id: String) {
self.credit_memo_id = Some(credit_memo_id);
self.disposition = ShortPaymentDisposition::CreditMemoIssued;
self.resolved_at = Some(Utc::now());
}
pub fn write_off(&mut self, write_off_je_id: String) {
self.write_off_je_id = Some(write_off_je_id);
self.disposition = ShortPaymentDisposition::WrittenOff;
self.resolved_at = Some(Utc::now());
}
pub fn rebill(&mut self, rebill_invoice_id: String) {
self.rebill_invoice_id = Some(rebill_invoice_id);
self.disposition = ShortPaymentDisposition::Rebilled;
self.resolved_at = Some(Utc::now());
}
pub fn accept(&mut self, credit_memo_id: Option<String>) {
self.credit_memo_id = credit_memo_id;
self.disposition = ShortPaymentDisposition::Accepted;
self.resolved_at = Some(Utc::now());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShortPaymentReasonCode {
PricingDispute,
QualityIssue,
QuantityDiscrepancy,
UnauthorizedDeduction,
IncorrectDiscount,
FreightDispute,
TaxDispute,
ReturnAllowance,
CoopDeduction,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ShortPaymentDisposition {
#[default]
Pending,
UnderInvestigation,
CreditMemoIssued,
WrittenOff,
Rebilled,
Accepted,
DisputeRejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnAccountPayment {
pub on_account_id: String,
pub company_code: String,
pub customer_id: String,
pub payment_id: String,
pub amount: Decimal,
pub remaining_amount: Decimal,
pub currency: String,
pub received_date: NaiveDate,
pub status: OnAccountStatus,
pub applications: Vec<OnAccountApplication>,
pub reason: Option<OnAccountReason>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
pub notes: Option<String>,
}
impl OnAccountPayment {
pub fn new(
on_account_id: String,
company_code: String,
customer_id: String,
payment_id: String,
amount: Decimal,
currency: String,
received_date: NaiveDate,
) -> Self {
Self {
on_account_id,
company_code,
customer_id,
payment_id,
amount,
remaining_amount: amount,
currency,
received_date,
status: OnAccountStatus::Unapplied,
applications: Vec::new(),
reason: None,
created_at: Utc::now(),
created_by: None,
notes: None,
}
}
pub fn with_reason(mut self, reason: OnAccountReason) -> Self {
self.reason = Some(reason);
self
}
pub fn apply_to_invoice(
&mut self,
invoice_id: String,
amount: Decimal,
application_date: NaiveDate,
) -> bool {
if amount > self.remaining_amount {
return false;
}
self.applications.push(OnAccountApplication {
invoice_id,
amount,
application_date,
});
self.remaining_amount -= amount;
if self.remaining_amount <= Decimal::ZERO {
self.status = OnAccountStatus::FullyApplied;
} else {
self.status = OnAccountStatus::PartiallyApplied;
}
true
}
pub fn refund(&mut self) {
self.status = OnAccountStatus::Refunded;
self.remaining_amount = Decimal::ZERO;
}
pub fn write_off(&mut self) {
self.status = OnAccountStatus::WrittenOff;
self.remaining_amount = Decimal::ZERO;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum OnAccountStatus {
#[default]
Unapplied,
PartiallyApplied,
FullyApplied,
Refunded,
WrittenOff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OnAccountReason {
NoInvoiceReference,
Overpayment,
Prepayment,
InvoicePending,
UnclearRemittance,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnAccountApplication {
pub invoice_id: String,
pub amount: Decimal,
pub application_date: NaiveDate,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_nsf_correction() {
let correction = PaymentCorrection::nsf(
"CORR-001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"PAY-001".to_string(),
Decimal::from(1000),
"USD".to_string(),
NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
"BANK-REF-123".to_string(),
Decimal::from(35),
);
assert_eq!(correction.correction_type, PaymentCorrectionType::NSF);
assert_eq!(correction.correction_amount, Decimal::from(1000));
assert_eq!(correction.fee_amount, Decimal::from(35));
assert_eq!(correction.status, CorrectionStatus::Pending);
}
#[test]
fn test_short_payment() {
let mut short = ShortPayment::new(
"SHORT-001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"PAY-001".to_string(),
"INV-001".to_string(),
Decimal::from(1000),
Decimal::from(950),
"USD".to_string(),
NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
ShortPaymentReasonCode::PricingDispute,
);
assert_eq!(short.short_amount, Decimal::from(50));
assert_eq!(short.disposition, ShortPaymentDisposition::Pending);
short.issue_credit_memo("CM-001".to_string());
assert_eq!(short.disposition, ShortPaymentDisposition::CreditMemoIssued);
assert!(short.credit_memo_id.is_some());
}
#[test]
fn test_on_account_payment() {
let mut on_account = OnAccountPayment::new(
"OA-001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"PAY-001".to_string(),
Decimal::from(500),
"USD".to_string(),
NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
);
assert_eq!(on_account.status, OnAccountStatus::Unapplied);
assert_eq!(on_account.remaining_amount, Decimal::from(500));
let applied = on_account.apply_to_invoice(
"INV-001".to_string(),
Decimal::from(300),
NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(),
);
assert!(applied);
assert_eq!(on_account.status, OnAccountStatus::PartiallyApplied);
assert_eq!(on_account.remaining_amount, Decimal::from(200));
let applied = on_account.apply_to_invoice(
"INV-002".to_string(),
Decimal::from(200),
NaiveDate::from_ymd_opt(2024, 3, 25).unwrap(),
);
assert!(applied);
assert_eq!(on_account.status, OnAccountStatus::FullyApplied);
assert_eq!(on_account.remaining_amount, Decimal::ZERO);
assert_eq!(on_account.applications.len(), 2);
}
#[test]
fn test_on_account_overapply_fails() {
let mut on_account = OnAccountPayment::new(
"OA-001".to_string(),
"1000".to_string(),
"CUST001".to_string(),
"PAY-001".to_string(),
Decimal::from(500),
"USD".to_string(),
NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
);
let applied = on_account.apply_to_invoice(
"INV-001".to_string(),
Decimal::from(600), NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(),
);
assert!(!applied);
assert_eq!(on_account.status, OnAccountStatus::Unapplied);
assert_eq!(on_account.remaining_amount, Decimal::from(500));
}
}