use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::{
DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
ReferenceType,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CustomerInvoiceType {
#[default]
Standard,
CreditMemo,
DebitMemo,
ProForma,
DownPaymentRequest,
FinalInvoice,
Intercompany,
}
impl CustomerInvoiceType {
pub fn is_debit(&self) -> bool {
matches!(
self,
Self::Standard
| Self::DebitMemo
| Self::DownPaymentRequest
| Self::FinalInvoice
| Self::Intercompany
)
}
pub fn is_credit(&self) -> bool {
matches!(self, Self::CreditMemo)
}
pub fn creates_revenue(&self) -> bool {
matches!(
self,
Self::Standard | Self::FinalInvoice | Self::Intercompany
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InvoicePaymentStatus {
#[default]
Open,
PartiallyPaid,
Paid,
Cleared,
WrittenOff,
InDispute,
InCollection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomerInvoiceItem {
#[serde(flatten)]
pub base: DocumentLineItem,
pub sales_order_id: Option<String>,
pub so_item: Option<u16>,
pub delivery_id: Option<String>,
pub delivery_item: Option<u16>,
pub revenue_account: Option<String>,
pub cogs_account: Option<String>,
pub cogs_amount: Decimal,
pub discount_amount: Decimal,
pub is_service: bool,
pub returns_reference: Option<String>,
}
impl CustomerInvoiceItem {
#[allow(clippy::too_many_arguments)]
pub fn new(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
) -> Self {
Self {
base: DocumentLineItem::new(line_number, description, quantity, unit_price),
sales_order_id: None,
so_item: None,
delivery_id: None,
delivery_item: None,
revenue_account: None,
cogs_account: None,
cogs_amount: Decimal::ZERO,
discount_amount: Decimal::ZERO,
is_service: false,
returns_reference: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_delivery(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
delivery_id: impl Into<String>,
delivery_item: u16,
) -> Self {
let mut item = Self::new(line_number, description, quantity, unit_price);
item.delivery_id = Some(delivery_id.into());
item.delivery_item = Some(delivery_item);
item
}
pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
self.base = self.base.with_material(material_id);
self
}
pub fn with_sales_order(mut self, so_id: impl Into<String>, so_item: u16) -> Self {
self.sales_order_id = Some(so_id.into());
self.so_item = Some(so_item);
self
}
pub fn with_cogs(mut self, cogs: Decimal) -> Self {
self.cogs_amount = cogs;
self
}
pub fn with_revenue_account(mut self, account: impl Into<String>) -> Self {
self.revenue_account = Some(account.into());
self
}
pub fn as_service(mut self) -> Self {
self.is_service = true;
self
}
pub fn with_discount(mut self, discount: Decimal) -> Self {
self.discount_amount = discount;
self
}
pub fn gross_margin(&self) -> Decimal {
if self.base.net_amount == Decimal::ZERO {
return Decimal::ZERO;
}
((self.base.net_amount - self.cogs_amount) / self.base.net_amount * Decimal::from(100))
.round_dp(2)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomerInvoice {
pub header: DocumentHeader,
pub invoice_type: CustomerInvoiceType,
pub items: Vec<CustomerInvoiceItem>,
pub customer_id: String,
pub bill_to: Option<String>,
pub payer: Option<String>,
pub sales_org: String,
pub distribution_channel: String,
pub division: String,
pub total_net_amount: Decimal,
pub total_tax_amount: Decimal,
pub total_gross_amount: Decimal,
pub total_discount: Decimal,
pub total_cogs: Decimal,
pub payment_terms: String,
pub due_date: NaiveDate,
pub discount_date_1: Option<NaiveDate>,
pub discount_percent_1: Option<Decimal>,
pub discount_date_2: Option<NaiveDate>,
pub discount_percent_2: Option<Decimal>,
pub amount_paid: Decimal,
pub amount_open: Decimal,
pub payment_status: InvoicePaymentStatus,
pub sales_order_id: Option<String>,
pub delivery_id: Option<String>,
pub external_reference: Option<String>,
pub customer_po_number: Option<String>,
pub is_posted: bool,
pub is_output_complete: bool,
pub is_intercompany: bool,
pub ic_partner: Option<String>,
pub dispute_reason: Option<String>,
pub write_off_amount: Decimal,
pub write_off_reason: Option<String>,
pub dunning_level: u8,
pub last_dunning_date: Option<NaiveDate>,
pub is_cancelled: bool,
pub cancellation_invoice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_name: Option<String>,
}
impl CustomerInvoice {
#[allow(clippy::too_many_arguments)]
pub fn new(
invoice_id: impl Into<String>,
company_code: impl Into<String>,
customer_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
due_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let header = DocumentHeader::new(
invoice_id,
DocumentType::CustomerInvoice,
company_code,
fiscal_year,
fiscal_period,
document_date,
created_by,
)
.with_currency("USD");
Self {
header,
invoice_type: CustomerInvoiceType::Standard,
items: Vec::new(),
customer_id: customer_id.into(),
bill_to: None,
payer: None,
sales_org: "1000".to_string(),
distribution_channel: "10".to_string(),
division: "00".to_string(),
total_net_amount: Decimal::ZERO,
total_tax_amount: Decimal::ZERO,
total_gross_amount: Decimal::ZERO,
total_discount: Decimal::ZERO,
total_cogs: Decimal::ZERO,
payment_terms: "NET30".to_string(),
due_date,
discount_date_1: None,
discount_percent_1: None,
discount_date_2: None,
discount_percent_2: None,
amount_paid: Decimal::ZERO,
amount_open: Decimal::ZERO,
payment_status: InvoicePaymentStatus::Open,
sales_order_id: None,
delivery_id: None,
external_reference: None,
customer_po_number: None,
is_posted: false,
is_output_complete: false,
is_intercompany: false,
ic_partner: None,
dispute_reason: None,
write_off_amount: Decimal::ZERO,
write_off_reason: None,
dunning_level: 0,
last_dunning_date: None,
is_cancelled: false,
cancellation_invoice: None,
customer_name: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_delivery(
invoice_id: impl Into<String>,
company_code: impl Into<String>,
delivery_id: impl Into<String>,
customer_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
due_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let dlv_id = delivery_id.into();
let mut invoice = Self::new(
invoice_id,
company_code,
customer_id,
fiscal_year,
fiscal_period,
document_date,
due_date,
created_by,
);
invoice.delivery_id = Some(dlv_id.clone());
invoice.header.add_reference(DocumentReference::new(
DocumentType::Delivery,
dlv_id,
DocumentType::CustomerInvoice,
invoice.header.document_id.clone(),
ReferenceType::FollowOn,
invoice.header.company_code.clone(),
document_date,
));
invoice
}
pub fn credit_memo(
invoice_id: impl Into<String>,
company_code: impl Into<String>,
customer_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let mut invoice = Self::new(
invoice_id,
company_code,
customer_id,
fiscal_year,
fiscal_period,
document_date,
document_date, created_by,
);
invoice.invoice_type = CustomerInvoiceType::CreditMemo;
invoice.header.document_type = DocumentType::CreditMemo;
invoice
}
pub fn with_invoice_type(mut self, invoice_type: CustomerInvoiceType) -> Self {
self.invoice_type = invoice_type;
self
}
pub fn with_sales_org(
mut self,
sales_org: impl Into<String>,
dist_channel: impl Into<String>,
division: impl Into<String>,
) -> Self {
self.sales_org = sales_org.into();
self.distribution_channel = dist_channel.into();
self.division = division.into();
self
}
pub fn with_partners(mut self, bill_to: impl Into<String>, payer: impl Into<String>) -> Self {
self.bill_to = Some(bill_to.into());
self.payer = Some(payer.into());
self
}
pub fn with_payment_terms(
mut self,
terms: impl Into<String>,
discount_days_1: Option<u16>,
discount_percent_1: Option<Decimal>,
) -> Self {
self.payment_terms = terms.into();
if let (Some(days), Some(pct)) = (discount_days_1, discount_percent_1) {
self.discount_date_1 =
Some(self.header.document_date + chrono::Duration::days(days as i64));
self.discount_percent_1 = Some(pct);
}
self
}
pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
self.customer_po_number = Some(po_number.into());
self
}
pub fn as_intercompany(mut self, partner_company: impl Into<String>) -> Self {
self.is_intercompany = true;
self.ic_partner = Some(partner_company.into());
self.invoice_type = CustomerInvoiceType::Intercompany;
self
}
pub fn add_item(&mut self, item: CustomerInvoiceItem) {
self.items.push(item);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
self.total_discount = self.items.iter().map(|i| i.discount_amount).sum();
self.total_cogs = self.items.iter().map(|i| i.cogs_amount).sum();
self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
}
pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
self.is_posted = true;
self.header.posting_date = Some(posting_date);
self.header.update_status(DocumentStatus::Posted, user);
self.recalculate_totals();
}
pub fn record_payment(&mut self, amount: Decimal, discount_taken: Decimal) {
self.amount_paid += amount + discount_taken;
self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
if self.amount_open <= Decimal::ZERO {
self.payment_status = InvoicePaymentStatus::Paid;
} else if self.amount_paid > Decimal::ZERO {
self.payment_status = InvoicePaymentStatus::PartiallyPaid;
}
}
pub fn clear(&mut self) {
self.payment_status = InvoicePaymentStatus::Cleared;
self.amount_open = Decimal::ZERO;
self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
}
pub fn dispute(&mut self, reason: impl Into<String>) {
self.payment_status = InvoicePaymentStatus::InDispute;
self.dispute_reason = Some(reason.into());
}
pub fn resolve_dispute(&mut self) {
self.dispute_reason = None;
if self.amount_open > Decimal::ZERO {
self.payment_status = if self.amount_paid > Decimal::ZERO {
InvoicePaymentStatus::PartiallyPaid
} else {
InvoicePaymentStatus::Open
};
} else {
self.payment_status = InvoicePaymentStatus::Paid;
}
}
pub fn write_off(&mut self, amount: Decimal, reason: impl Into<String>) {
self.write_off_amount = amount;
self.write_off_reason = Some(reason.into());
self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
if self.amount_open <= Decimal::ZERO {
self.payment_status = InvoicePaymentStatus::WrittenOff;
}
}
pub fn record_dunning(&mut self, dunning_date: NaiveDate) {
self.dunning_level += 1;
self.last_dunning_date = Some(dunning_date);
if self.dunning_level >= 4 {
self.payment_status = InvoicePaymentStatus::InCollection;
}
}
pub fn cancel(&mut self, user: impl Into<String>, cancellation_invoice: impl Into<String>) {
self.is_cancelled = true;
self.cancellation_invoice = Some(cancellation_invoice.into());
self.header.update_status(DocumentStatus::Cancelled, user);
}
pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
self.payment_status == InvoicePaymentStatus::Open && as_of_date > self.due_date
}
pub fn days_past_due(&self, as_of_date: NaiveDate) -> i64 {
if as_of_date <= self.due_date {
0
} else {
(as_of_date - self.due_date).num_days()
}
}
pub fn aging_bucket(&self, as_of_date: NaiveDate) -> AgingBucket {
let days = self.days_past_due(as_of_date);
match days {
d if d <= 0 => AgingBucket::Current,
1..=30 => AgingBucket::Days1To30,
31..=60 => AgingBucket::Days31To60,
61..=90 => AgingBucket::Days61To90,
_ => AgingBucket::Over90,
}
}
pub fn cash_discount_available(&self, as_of_date: NaiveDate) -> Decimal {
if let (Some(date1), Some(pct1)) = (self.discount_date_1, self.discount_percent_1) {
if as_of_date <= date1 {
return self.amount_open * pct1 / Decimal::from(100);
}
}
if let (Some(date2), Some(pct2)) = (self.discount_date_2, self.discount_percent_2) {
if as_of_date <= date2 {
return self.amount_open * pct2 / Decimal::from(100);
}
}
Decimal::ZERO
}
pub fn gross_margin(&self) -> Decimal {
if self.total_net_amount == Decimal::ZERO {
return Decimal::ZERO;
}
((self.total_net_amount - self.total_cogs) / self.total_net_amount * Decimal::from(100))
.round_dp(2)
}
pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
let mut entries = Vec::new();
let sign = if self.invoice_type.is_debit() { 1 } else { -1 };
let ar_account = "120000".to_string();
if sign > 0 {
entries.push((ar_account, self.total_gross_amount, Decimal::ZERO));
} else {
entries.push((ar_account, Decimal::ZERO, self.total_gross_amount));
}
for item in &self.items {
let revenue_account = item
.revenue_account
.clone()
.or_else(|| item.base.gl_account.clone())
.unwrap_or_else(|| "400000".to_string());
if sign > 0 {
entries.push((revenue_account, Decimal::ZERO, item.base.net_amount));
} else {
entries.push((revenue_account, item.base.net_amount, Decimal::ZERO));
}
}
if self.total_tax_amount > Decimal::ZERO {
let tax_account = "220000".to_string();
if sign > 0 {
entries.push((tax_account, Decimal::ZERO, self.total_tax_amount));
} else {
entries.push((tax_account, self.total_tax_amount, Decimal::ZERO));
}
}
entries
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AgingBucket {
Current,
Days1To30,
Days31To60,
Days61To90,
Over90,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_customer_invoice_creation() {
let invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
assert_eq!(invoice.customer_id, "C-000001");
assert_eq!(invoice.payment_status, InvoicePaymentStatus::Open);
}
#[test]
fn test_customer_invoice_from_delivery() {
let invoice = CustomerInvoice::from_delivery(
"CI-1000-0000000001",
"1000",
"DLV-1000-0000000001",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
assert_eq!(invoice.delivery_id, Some("DLV-1000-0000000001".to_string()));
assert_eq!(invoice.header.document_references.len(), 1);
}
#[test]
fn test_invoice_items() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
let item = CustomerInvoiceItem::from_delivery(
1,
"Product A",
Decimal::from(100),
Decimal::from(50),
"DLV-1000-0000000001",
1,
)
.with_material("MAT-001")
.with_cogs(Decimal::from(3000));
invoice.add_item(item);
assert_eq!(invoice.total_net_amount, Decimal::from(5000));
assert_eq!(invoice.total_cogs, Decimal::from(3000));
assert_eq!(invoice.gross_margin(), Decimal::from(40)); }
#[test]
fn test_payment_recording() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
invoice.add_item(item);
invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
assert_eq!(invoice.amount_open, Decimal::from(1000));
invoice.record_payment(Decimal::from(500), Decimal::ZERO);
assert_eq!(invoice.amount_paid, Decimal::from(500));
assert_eq!(invoice.amount_open, Decimal::from(500));
assert_eq!(invoice.payment_status, InvoicePaymentStatus::PartiallyPaid);
invoice.record_payment(Decimal::from(500), Decimal::ZERO);
assert_eq!(invoice.payment_status, InvoicePaymentStatus::Paid);
}
#[test]
fn test_cash_discount() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
)
.with_payment_terms("2/10 NET 30", Some(10), Some(Decimal::from(2)));
let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
invoice.add_item(item);
invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
let discount =
invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
assert_eq!(discount, Decimal::from(20));
let discount =
invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 30).unwrap());
assert_eq!(discount, Decimal::ZERO);
}
#[test]
fn test_aging() {
let invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
assert!(!invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()));
assert_eq!(
invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()),
AgingBucket::Current
);
assert!(invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()));
assert_eq!(
invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
AgingBucket::Days1To30
);
assert_eq!(
invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
AgingBucket::Days31To60
);
assert_eq!(
invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()),
AgingBucket::Over90
);
}
#[test]
fn test_gl_entry_generation() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
let mut item =
CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
item.base.tax_amount = Decimal::from(100);
invoice.add_item(item);
invoice.recalculate_totals();
let entries = invoice.generate_gl_entries();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].0, "120000");
assert_eq!(entries[0].1, Decimal::from(1100));
assert_eq!(entries[1].0, "400000");
assert_eq!(entries[1].2, Decimal::from(1000));
assert_eq!(entries[2].0, "220000");
assert_eq!(entries[2].2, Decimal::from(100));
}
#[test]
fn test_credit_memo_gl_entries() {
let mut invoice = CustomerInvoice::credit_memo(
"CM-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
let item = CustomerInvoiceItem::new(1, "Return", Decimal::from(5), Decimal::from(100));
invoice.add_item(item);
let entries = invoice.generate_gl_entries();
assert_eq!(entries[0].0, "120000");
assert_eq!(entries[0].2, Decimal::from(500));
assert_eq!(entries[1].0, "400000");
assert_eq!(entries[1].1, Decimal::from(500)); }
#[test]
fn test_write_off() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
invoice.add_item(item);
invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
invoice.record_payment(Decimal::from(900), Decimal::ZERO);
invoice.write_off(Decimal::from(100), "Small balance write-off");
assert_eq!(invoice.write_off_amount, Decimal::from(100));
assert_eq!(invoice.amount_open, Decimal::ZERO);
assert_eq!(invoice.payment_status, InvoicePaymentStatus::WrittenOff);
}
#[test]
fn test_dunning() {
let mut invoice = CustomerInvoice::new(
"CI-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
"JSMITH",
);
invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 2, 20).unwrap());
assert_eq!(invoice.dunning_level, 1);
invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 5).unwrap());
invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 20).unwrap());
invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 4, 5).unwrap());
assert_eq!(invoice.dunning_level, 4);
assert_eq!(invoice.payment_status, InvoicePaymentStatus::InCollection);
}
}