use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocumentType {
PurchaseRequisition,
PurchaseOrder,
GoodsReceipt,
VendorInvoice,
ApPayment,
DebitMemo,
SalesQuote,
SalesOrder,
Delivery,
CustomerInvoice,
CustomerReceipt,
CreditMemo,
JournalEntry,
AssetAcquisition,
DepreciationRun,
IntercompanyDocument,
General,
}
impl DocumentType {
pub fn prefix(&self) -> &'static str {
match self {
Self::PurchaseRequisition => "PR",
Self::PurchaseOrder => "PO",
Self::GoodsReceipt => "GR",
Self::VendorInvoice => "VI",
Self::ApPayment => "AP",
Self::DebitMemo => "DM",
Self::SalesQuote => "SQ",
Self::SalesOrder => "SO",
Self::Delivery => "DL",
Self::CustomerInvoice => "CI",
Self::CustomerReceipt => "CR",
Self::CreditMemo => "CM",
Self::JournalEntry => "JE",
Self::AssetAcquisition => "AA",
Self::DepreciationRun => "DR",
Self::IntercompanyDocument => "IC",
Self::General => "GN",
}
}
pub fn creates_gl_entry(&self) -> bool {
!matches!(
self,
Self::PurchaseRequisition | Self::PurchaseOrder | Self::SalesQuote | Self::SalesOrder
)
}
pub fn business_process(&self) -> &'static str {
match self {
Self::PurchaseRequisition
| Self::PurchaseOrder
| Self::GoodsReceipt
| Self::VendorInvoice
| Self::ApPayment
| Self::DebitMemo => "P2P",
Self::SalesQuote
| Self::SalesOrder
| Self::Delivery
| Self::CustomerInvoice
| Self::CustomerReceipt
| Self::CreditMemo => "O2C",
Self::JournalEntry => "R2R",
Self::AssetAcquisition | Self::DepreciationRun => "A2R",
Self::IntercompanyDocument => "IC",
Self::General => "GEN",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReferenceType {
FollowOn,
Payment,
Reversal,
Partial,
CreditMemo,
DebitMemo,
Return,
IntercompanyMatch,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentReference {
pub reference_id: Uuid,
pub source_doc_type: DocumentType,
pub source_doc_id: String,
pub target_doc_type: DocumentType,
pub target_doc_id: String,
pub reference_type: ReferenceType,
pub company_code: String,
pub reference_date: NaiveDate,
pub description: Option<String>,
pub reference_amount: Option<rust_decimal::Decimal>,
}
impl DocumentReference {
pub fn new(
source_type: DocumentType,
source_id: impl Into<String>,
target_type: DocumentType,
target_id: impl Into<String>,
ref_type: ReferenceType,
company_code: impl Into<String>,
date: NaiveDate,
) -> Self {
Self {
reference_id: Uuid::new_v4(),
source_doc_type: source_type,
source_doc_id: source_id.into(),
target_doc_type: target_type,
target_doc_id: target_id.into(),
reference_type: ref_type,
company_code: company_code.into(),
reference_date: date,
description: None,
reference_amount: None,
}
}
pub fn follow_on(
source_type: DocumentType,
source_id: impl Into<String>,
target_type: DocumentType,
target_id: impl Into<String>,
company_code: impl Into<String>,
date: NaiveDate,
) -> Self {
Self::new(
source_type,
source_id,
target_type,
target_id,
ReferenceType::FollowOn,
company_code,
date,
)
}
pub fn payment(
invoice_type: DocumentType,
invoice_id: impl Into<String>,
payment_id: impl Into<String>,
company_code: impl Into<String>,
date: NaiveDate,
amount: rust_decimal::Decimal,
) -> Self {
let payment_type = match invoice_type {
DocumentType::VendorInvoice => DocumentType::ApPayment,
DocumentType::CustomerInvoice => DocumentType::CustomerReceipt,
_ => DocumentType::ApPayment,
};
let mut reference = Self::new(
invoice_type,
invoice_id,
payment_type,
payment_id,
ReferenceType::Payment,
company_code,
date,
);
reference.reference_amount = Some(amount);
reference
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_amount(mut self, amount: rust_decimal::Decimal) -> Self {
self.reference_amount = Some(amount);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DocumentStatus {
#[default]
Draft,
Submitted,
PendingApproval,
Approved,
Rejected,
Released,
PartiallyProcessed,
Completed,
Cancelled,
Posted,
Cleared,
}
impl DocumentStatus {
pub fn is_editable(&self) -> bool {
matches!(self, Self::Draft | Self::Rejected)
}
pub fn can_cancel(&self) -> bool {
!matches!(self, Self::Cancelled | Self::Cleared | Self::Completed)
}
pub fn needs_approval(&self) -> bool {
matches!(self, Self::Submitted | Self::PendingApproval)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentHeader {
pub document_id: String,
pub document_type: DocumentType,
pub company_code: String,
pub fiscal_year: u16,
pub fiscal_period: u8,
pub document_date: NaiveDate,
pub posting_date: Option<NaiveDate>,
pub entry_date: NaiveDate,
#[serde(with = "crate::serde_timestamp::naive")]
pub entry_timestamp: NaiveDateTime,
pub status: DocumentStatus,
pub created_by: String,
pub changed_by: Option<String>,
#[serde(default, with = "crate::serde_timestamp::naive::option")]
pub changed_at: Option<NaiveDateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by_employee_id: Option<String>,
pub currency: String,
pub reference: Option<String>,
pub header_text: Option<String>,
pub journal_entry_id: Option<String>,
pub document_references: Vec<DocumentReference>,
#[serde(default)]
pub is_fraud: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fraud_type: Option<crate::models::FraudType>,
}
impl DocumentHeader {
pub fn new(
document_id: impl Into<String>,
document_type: DocumentType,
company_code: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let now = chrono::Utc::now().naive_utc();
Self {
document_id: document_id.into(),
document_type,
company_code: company_code.into(),
fiscal_year,
fiscal_period,
document_date,
posting_date: None,
entry_date: document_date,
entry_timestamp: now,
status: DocumentStatus::Draft,
created_by: created_by.into(),
changed_by: None,
changed_at: None,
created_by_employee_id: None,
currency: "USD".to_string(),
reference: None,
header_text: None,
journal_entry_id: None,
document_references: Vec::new(),
is_fraud: false,
fraud_type: None,
}
}
pub fn propagate_fraud(
&mut self,
fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
) -> bool {
if let Some(ft) = fraud_map.get(&self.document_id) {
self.is_fraud = true;
self.fraud_type = Some(*ft);
return true;
}
if let Some(ref je_id) = self.journal_entry_id {
if let Some(ft) = fraud_map.get(je_id) {
self.is_fraud = true;
self.fraud_type = Some(*ft);
return true;
}
}
false
}
pub fn with_created_by_employee_id(mut self, employee_id: impl Into<String>) -> Self {
self.created_by_employee_id = Some(employee_id.into());
self
}
pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
self.posting_date = Some(date);
self
}
pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
self.currency = currency.into();
self
}
pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
self.reference = Some(reference.into());
self
}
pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
self.header_text = Some(text.into());
self
}
pub fn add_reference(&mut self, reference: DocumentReference) {
self.document_references.push(reference);
}
pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
self.status = new_status;
self.changed_by = Some(user.into());
self.changed_at = Some(chrono::Utc::now().naive_utc());
}
pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentLineItem {
pub line_number: u16,
pub material_id: Option<String>,
pub description: String,
pub quantity: rust_decimal::Decimal,
pub uom: String,
pub unit_price: rust_decimal::Decimal,
pub net_amount: rust_decimal::Decimal,
pub tax_amount: rust_decimal::Decimal,
pub gross_amount: rust_decimal::Decimal,
pub gl_account: Option<String>,
pub cost_center: Option<String>,
pub profit_center: Option<String>,
pub internal_order: Option<String>,
pub wbs_element: Option<String>,
pub delivery_date: Option<NaiveDate>,
pub plant: Option<String>,
pub storage_location: Option<String>,
pub line_text: Option<String>,
pub is_cancelled: bool,
}
impl DocumentLineItem {
pub fn new(
line_number: u16,
description: impl Into<String>,
quantity: rust_decimal::Decimal,
unit_price: rust_decimal::Decimal,
) -> Self {
let net_amount = quantity * unit_price;
Self {
line_number,
material_id: None,
description: description.into(),
quantity,
uom: "EA".to_string(),
unit_price,
net_amount,
tax_amount: rust_decimal::Decimal::ZERO,
gross_amount: net_amount,
gl_account: None,
cost_center: None,
profit_center: None,
internal_order: None,
wbs_element: None,
delivery_date: None,
plant: None,
storage_location: None,
line_text: None,
is_cancelled: false,
}
}
pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
self.material_id = Some(material_id.into());
self
}
pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
self.gl_account = Some(account.into());
self
}
pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
self.cost_center = Some(cost_center.into());
self
}
pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
self.tax_amount = tax_amount;
self.gross_amount = self.net_amount + tax_amount;
self
}
pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
self.uom = uom.into();
self
}
pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
self.delivery_date = Some(date);
self
}
pub fn recalculate(&mut self) {
self.net_amount = self.quantity * self.unit_price;
self.gross_amount = self.net_amount + self.tax_amount;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_document_type_prefix() {
assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
}
#[test]
fn test_document_reference() {
let reference = DocumentReference::follow_on(
DocumentType::PurchaseOrder,
"PO-1000-0000000001",
DocumentType::GoodsReceipt,
"GR-1000-0000000001",
"1000",
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
assert_eq!(reference.reference_type, ReferenceType::FollowOn);
assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
}
#[test]
fn test_document_header() {
let header = DocumentHeader::new(
"PO-1000-0000000001",
DocumentType::PurchaseOrder,
"1000",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
)
.with_currency("EUR")
.with_reference("EXT-REF-123");
assert_eq!(header.currency, "EUR");
assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
assert_eq!(header.status, DocumentStatus::Draft);
}
#[test]
fn test_document_line_item() {
let item = DocumentLineItem::new(
1,
"Office Supplies",
rust_decimal::Decimal::from(10),
rust_decimal::Decimal::from(25),
)
.with_tax(rust_decimal::Decimal::from(25));
assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
}
#[test]
fn test_document_status() {
assert!(DocumentStatus::Draft.is_editable());
assert!(!DocumentStatus::Posted.is_editable());
assert!(DocumentStatus::Released.can_cancel());
assert!(!DocumentStatus::Cancelled.can_cancel());
}
}