use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::models::subledger::{CurrencyAmount, GLReference};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APPayment {
pub payment_number: String,
pub company_code: String,
pub vendor_id: String,
pub vendor_name: String,
pub payment_date: NaiveDate,
pub posting_date: NaiveDate,
pub value_date: NaiveDate,
pub payment_type: APPaymentType,
pub status: PaymentStatus,
pub amount: CurrencyAmount,
pub bank_charges: Decimal,
pub discount_taken: Decimal,
pub withholding_tax: Decimal,
pub net_payment: Decimal,
pub payment_method: APPaymentMethod,
pub house_bank: String,
pub bank_account: String,
pub vendor_bank_account: Option<String>,
pub check_number: Option<String>,
pub wire_reference: Option<String>,
pub paid_invoices: Vec<PaymentAllocation>,
pub gl_references: Vec<GLReference>,
pub payment_run_id: Option<String>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
pub approved_by: Option<String>,
pub approved_date: Option<NaiveDate>,
pub notes: Option<String>,
}
impl APPayment {
#[allow(clippy::too_many_arguments)]
pub fn new(
payment_number: String,
company_code: String,
vendor_id: String,
vendor_name: String,
payment_date: NaiveDate,
amount: Decimal,
currency: String,
payment_method: APPaymentMethod,
house_bank: String,
bank_account: String,
) -> Self {
Self {
payment_number,
company_code,
vendor_id,
vendor_name,
payment_date,
posting_date: payment_date,
value_date: payment_date,
payment_type: APPaymentType::Standard,
status: PaymentStatus::Created,
amount: CurrencyAmount::single_currency(amount, currency),
bank_charges: Decimal::ZERO,
discount_taken: Decimal::ZERO,
withholding_tax: Decimal::ZERO,
net_payment: amount,
payment_method,
house_bank,
bank_account,
vendor_bank_account: None,
check_number: None,
wire_reference: None,
paid_invoices: Vec::new(),
gl_references: Vec::new(),
payment_run_id: None,
created_at: Utc::now(),
created_by: None,
approved_by: None,
approved_date: None,
notes: None,
}
}
pub fn allocate_to_invoice(
&mut self,
invoice_number: String,
amount_paid: Decimal,
discount: Decimal,
withholding: Decimal,
) {
let allocation = PaymentAllocation {
invoice_number,
amount_paid,
discount_taken: discount,
withholding_tax: withholding,
allocation_date: self.payment_date,
};
self.paid_invoices.push(allocation);
self.discount_taken += discount;
self.withholding_tax += withholding;
self.recalculate_net_payment();
}
fn recalculate_net_payment(&mut self) {
self.net_payment = self.amount.document_amount
- self.discount_taken
- self.withholding_tax
- self.bank_charges;
}
pub fn with_bank_charges(mut self, charges: Decimal) -> Self {
self.bank_charges = charges;
self.recalculate_net_payment();
self
}
pub fn with_check(mut self, check_number: String) -> Self {
self.check_number = Some(check_number);
self.payment_method = APPaymentMethod::Check;
self
}
pub fn with_wire_reference(mut self, reference: String) -> Self {
self.wire_reference = Some(reference);
self
}
pub fn with_vendor_bank(mut self, bank_account: String) -> Self {
self.vendor_bank_account = Some(bank_account);
self
}
pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
self.status = PaymentStatus::Approved;
self.approved_by = Some(approver);
self.approved_date = Some(approval_date);
}
pub fn release(&mut self) {
if self.status == PaymentStatus::Approved {
self.status = PaymentStatus::Released;
}
}
pub fn confirm_sent(&mut self, reference: Option<String>) {
self.status = PaymentStatus::Sent;
if let Some(ref_num) = reference {
self.wire_reference = Some(ref_num);
}
}
pub fn confirm_cleared(&mut self, value_date: NaiveDate) {
self.status = PaymentStatus::Cleared;
self.value_date = value_date;
}
pub fn void(&mut self, reason: String) {
self.status = PaymentStatus::Voided;
self.notes = Some(format!(
"{}Voided: {}",
self.notes
.as_ref()
.map(|n| format!("{n}. "))
.unwrap_or_default(),
reason
));
}
pub fn total_settlement(&self) -> Decimal {
self.paid_invoices
.iter()
.map(PaymentAllocation::total_settlement)
.sum()
}
pub fn add_gl_reference(&mut self, reference: GLReference) {
self.gl_references.push(reference);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum APPaymentType {
#[default]
Standard,
DownPayment,
Partial,
Final,
Urgent,
Intercompany,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PaymentStatus {
#[default]
Created,
Approved,
Released,
Sent,
Cleared,
Voided,
Returned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum APPaymentMethod {
#[default]
WireTransfer,
ACH,
Check,
SEPA,
CreditCard,
VirtualCard,
Netting,
LetterOfCredit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentAllocation {
pub invoice_number: String,
pub amount_paid: Decimal,
pub discount_taken: Decimal,
pub withholding_tax: Decimal,
pub allocation_date: NaiveDate,
}
impl PaymentAllocation {
pub fn total_settlement(&self) -> Decimal {
self.amount_paid + self.discount_taken + self.withholding_tax
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentProposal {
pub proposal_id: String,
pub company_code: String,
pub run_date: NaiveDate,
pub payment_date: NaiveDate,
pub status: ProposalStatus,
pub payment_method: APPaymentMethod,
pub proposed_payments: Vec<ProposedPayment>,
pub total_amount: Decimal,
pub total_discount: Decimal,
pub created_by: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
}
impl PaymentProposal {
pub fn new(
proposal_id: String,
company_code: String,
run_date: NaiveDate,
payment_date: NaiveDate,
payment_method: APPaymentMethod,
created_by: String,
) -> Self {
Self {
proposal_id,
company_code,
run_date,
payment_date,
status: ProposalStatus::Draft,
payment_method,
proposed_payments: Vec::new(),
total_amount: Decimal::ZERO,
total_discount: Decimal::ZERO,
created_by,
created_at: Utc::now(),
}
}
pub fn add_payment(&mut self, payment: ProposedPayment) {
self.total_amount += payment.amount;
self.total_discount += payment.discount;
self.proposed_payments.push(payment);
}
pub fn payment_count(&self) -> usize {
self.proposed_payments.len()
}
pub fn invoice_count(&self) -> usize {
self.proposed_payments
.iter()
.map(|p| p.invoices.len())
.sum()
}
pub fn submit(&mut self) {
self.status = ProposalStatus::Submitted;
}
pub fn approve(&mut self) {
self.status = ProposalStatus::Approved;
}
pub fn execute(&mut self) {
self.status = ProposalStatus::Executed;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProposalStatus {
Draft,
Submitted,
Approved,
Executed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposedPayment {
pub vendor_id: String,
pub vendor_name: String,
pub amount: Decimal,
pub discount: Decimal,
pub withholding_tax: Decimal,
pub net_payment: Decimal,
pub currency: String,
pub invoices: Vec<ProposedInvoice>,
pub is_selected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposedInvoice {
pub invoice_number: String,
pub invoice_date: NaiveDate,
pub due_date: NaiveDate,
pub open_amount: Decimal,
pub payment_amount: Decimal,
pub discount: Decimal,
pub days_until_due: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentRunConfig {
pub company_codes: Vec<String>,
pub payment_methods: Vec<APPaymentMethod>,
pub due_date_cutoff: NaiveDate,
pub include_discount_items: bool,
pub discount_date_cutoff: Option<NaiveDate>,
pub max_amount_per_vendor: Option<Decimal>,
pub min_payment_amount: Decimal,
pub exclude_blocked: bool,
pub vendor_filter: Vec<String>,
}
impl Default for PaymentRunConfig {
fn default() -> Self {
Self {
company_codes: Vec::new(),
payment_methods: vec![APPaymentMethod::WireTransfer, APPaymentMethod::Check],
due_date_cutoff: chrono::Local::now().date_naive() + chrono::Duration::days(7),
include_discount_items: true,
discount_date_cutoff: None,
max_amount_per_vendor: None,
min_payment_amount: dec!(0.01),
exclude_blocked: true,
vendor_filter: Vec::new(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_payment_creation() {
let payment = APPayment::new(
"PAY001".to_string(),
"1000".to_string(),
"VEND001".to_string(),
"Test Vendor".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
dec!(1000),
"USD".to_string(),
APPaymentMethod::WireTransfer,
"BANK01".to_string(),
"100001".to_string(),
);
assert_eq!(payment.amount.document_amount, dec!(1000));
assert_eq!(payment.status, PaymentStatus::Created);
}
#[test]
fn test_payment_allocation() {
let mut payment = APPayment::new(
"PAY001".to_string(),
"1000".to_string(),
"VEND001".to_string(),
"Test Vendor".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
dec!(1000),
"USD".to_string(),
APPaymentMethod::WireTransfer,
"BANK01".to_string(),
"100001".to_string(),
);
payment.allocate_to_invoice(
"INV001".to_string(),
dec!(980),
dec!(20), Decimal::ZERO,
);
assert_eq!(payment.discount_taken, dec!(20));
assert_eq!(payment.total_settlement(), dec!(1000));
assert_eq!(payment.paid_invoices.len(), 1);
}
#[test]
fn test_payment_workflow() {
let mut payment = APPayment::new(
"PAY001".to_string(),
"1000".to_string(),
"VEND001".to_string(),
"Test Vendor".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
dec!(1000),
"USD".to_string(),
APPaymentMethod::Check,
"BANK01".to_string(),
"100001".to_string(),
)
.with_check("CHK12345".to_string());
assert_eq!(payment.status, PaymentStatus::Created);
payment.approve(
"APPROVER1".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
);
assert_eq!(payment.status, PaymentStatus::Approved);
payment.release();
assert_eq!(payment.status, PaymentStatus::Released);
payment.confirm_sent(None);
assert_eq!(payment.status, PaymentStatus::Sent);
payment.confirm_cleared(NaiveDate::from_ymd_opt(2024, 2, 18).unwrap());
assert_eq!(payment.status, PaymentStatus::Cleared);
}
#[test]
fn test_payment_proposal() {
let mut proposal = PaymentProposal::new(
"PROP001".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
APPaymentMethod::WireTransfer,
"USER1".to_string(),
);
let payment = ProposedPayment {
vendor_id: "VEND001".to_string(),
vendor_name: "Test Vendor".to_string(),
amount: dec!(5000),
discount: dec!(100),
withholding_tax: Decimal::ZERO,
net_payment: dec!(4900),
currency: "USD".to_string(),
invoices: vec![ProposedInvoice {
invoice_number: "INV001".to_string(),
invoice_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
due_date: NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
open_amount: dec!(5000),
payment_amount: dec!(4900),
discount: dec!(100),
days_until_due: 0,
}],
is_selected: true,
};
proposal.add_payment(payment);
assert_eq!(proposal.payment_count(), 1);
assert_eq!(proposal.invoice_count(), 1);
assert_eq!(proposal.total_amount, dec!(5000));
assert_eq!(proposal.total_discount, dec!(100));
}
}