1use chrono::{NaiveDate, NaiveDateTime};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DocumentType {
14 PurchaseRequisition,
17 PurchaseOrder,
19 GoodsReceipt,
21 VendorInvoice,
23 ApPayment,
25 DebitMemo,
27
28 SalesQuote,
31 SalesOrder,
33 Delivery,
35 CustomerInvoice,
37 CustomerReceipt,
39 CreditMemo,
41
42 JournalEntry,
45 AssetAcquisition,
47 DepreciationRun,
49 IntercompanyDocument,
51
52 General,
55}
56
57impl DocumentType {
58 pub fn prefix(&self) -> &'static str {
60 match self {
61 Self::PurchaseRequisition => "PR",
62 Self::PurchaseOrder => "PO",
63 Self::GoodsReceipt => "GR",
64 Self::VendorInvoice => "VI",
65 Self::ApPayment => "AP",
66 Self::DebitMemo => "DM",
67 Self::SalesQuote => "SQ",
68 Self::SalesOrder => "SO",
69 Self::Delivery => "DL",
70 Self::CustomerInvoice => "CI",
71 Self::CustomerReceipt => "CR",
72 Self::CreditMemo => "CM",
73 Self::JournalEntry => "JE",
74 Self::AssetAcquisition => "AA",
75 Self::DepreciationRun => "DR",
76 Self::IntercompanyDocument => "IC",
77 Self::General => "GN",
78 }
79 }
80
81 pub fn creates_gl_entry(&self) -> bool {
83 !matches!(
84 self,
85 Self::PurchaseRequisition | Self::PurchaseOrder | Self::SalesQuote | Self::SalesOrder
86 )
87 }
88
89 pub fn business_process(&self) -> &'static str {
91 match self {
92 Self::PurchaseRequisition
93 | Self::PurchaseOrder
94 | Self::GoodsReceipt
95 | Self::VendorInvoice
96 | Self::ApPayment
97 | Self::DebitMemo => "P2P",
98
99 Self::SalesQuote
100 | Self::SalesOrder
101 | Self::Delivery
102 | Self::CustomerInvoice
103 | Self::CustomerReceipt
104 | Self::CreditMemo => "O2C",
105
106 Self::JournalEntry => "R2R",
107 Self::AssetAcquisition | Self::DepreciationRun => "A2R",
108 Self::IntercompanyDocument => "IC",
109 Self::General => "GEN",
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReferenceType {
118 FollowOn,
120 Payment,
122 Reversal,
124 Partial,
126 CreditMemo,
128 DebitMemo,
130 Return,
132 IntercompanyMatch,
134 Manual,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct DocumentReference {
141 pub reference_id: Uuid,
143
144 pub source_doc_type: DocumentType,
146
147 pub source_doc_id: String,
149
150 pub target_doc_type: DocumentType,
152
153 pub target_doc_id: String,
155
156 pub reference_type: ReferenceType,
158
159 pub company_code: String,
161
162 pub reference_date: NaiveDate,
164
165 pub description: Option<String>,
167
168 pub reference_amount: Option<rust_decimal::Decimal>,
170}
171
172impl DocumentReference {
173 pub fn new(
175 source_type: DocumentType,
176 source_id: impl Into<String>,
177 target_type: DocumentType,
178 target_id: impl Into<String>,
179 ref_type: ReferenceType,
180 company_code: impl Into<String>,
181 date: NaiveDate,
182 ) -> Self {
183 Self {
184 reference_id: Uuid::new_v4(),
185 source_doc_type: source_type,
186 source_doc_id: source_id.into(),
187 target_doc_type: target_type,
188 target_doc_id: target_id.into(),
189 reference_type: ref_type,
190 company_code: company_code.into(),
191 reference_date: date,
192 description: None,
193 reference_amount: None,
194 }
195 }
196
197 pub fn follow_on(
199 source_type: DocumentType,
200 source_id: impl Into<String>,
201 target_type: DocumentType,
202 target_id: impl Into<String>,
203 company_code: impl Into<String>,
204 date: NaiveDate,
205 ) -> Self {
206 Self::new(
207 source_type,
208 source_id,
209 target_type,
210 target_id,
211 ReferenceType::FollowOn,
212 company_code,
213 date,
214 )
215 }
216
217 pub fn payment(
219 invoice_type: DocumentType,
220 invoice_id: impl Into<String>,
221 payment_id: impl Into<String>,
222 company_code: impl Into<String>,
223 date: NaiveDate,
224 amount: rust_decimal::Decimal,
225 ) -> Self {
226 let payment_type = match invoice_type {
227 DocumentType::VendorInvoice => DocumentType::ApPayment,
228 DocumentType::CustomerInvoice => DocumentType::CustomerReceipt,
229 _ => DocumentType::ApPayment,
230 };
231
232 let mut reference = Self::new(
233 invoice_type,
234 invoice_id,
235 payment_type,
236 payment_id,
237 ReferenceType::Payment,
238 company_code,
239 date,
240 );
241 reference.reference_amount = Some(amount);
242 reference
243 }
244
245 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
247 self.description = Some(desc.into());
248 self
249 }
250
251 pub fn with_amount(mut self, amount: rust_decimal::Decimal) -> Self {
253 self.reference_amount = Some(amount);
254 self
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
260#[serde(rename_all = "snake_case")]
261pub enum DocumentStatus {
262 #[default]
264 Draft,
265 Submitted,
267 PendingApproval,
269 Approved,
271 Rejected,
273 Released,
275 PartiallyProcessed,
277 Completed,
279 Cancelled,
281 Posted,
283 Cleared,
285}
286
287impl DocumentStatus {
288 pub fn is_editable(&self) -> bool {
290 matches!(self, Self::Draft | Self::Rejected)
291 }
292
293 pub fn can_cancel(&self) -> bool {
295 !matches!(self, Self::Cancelled | Self::Cleared | Self::Completed)
296 }
297
298 pub fn needs_approval(&self) -> bool {
300 matches!(self, Self::Submitted | Self::PendingApproval)
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct DocumentHeader {
307 pub document_id: String,
309
310 pub document_type: DocumentType,
312
313 pub company_code: String,
315
316 pub fiscal_year: u16,
318
319 pub fiscal_period: u8,
321
322 pub document_date: NaiveDate,
324
325 pub posting_date: Option<NaiveDate>,
327
328 pub entry_date: NaiveDate,
330
331 #[serde(with = "crate::serde_timestamp::naive")]
333 pub entry_timestamp: NaiveDateTime,
334
335 pub status: DocumentStatus,
337
338 pub created_by: String,
340
341 pub changed_by: Option<String>,
343
344 #[serde(default, with = "crate::serde_timestamp::naive::option")]
346 pub changed_at: Option<NaiveDateTime>,
347
348 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub created_by_employee_id: Option<String>,
357
358 pub currency: String,
360
361 pub reference: Option<String>,
363
364 pub header_text: Option<String>,
366
367 pub journal_entry_id: Option<String>,
369
370 pub document_references: Vec<DocumentReference>,
372
373 #[serde(default)]
376 pub is_fraud: bool,
377
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub fraud_type: Option<crate::models::FraudType>,
381}
382
383impl DocumentHeader {
384 pub fn new(
386 document_id: impl Into<String>,
387 document_type: DocumentType,
388 company_code: impl Into<String>,
389 fiscal_year: u16,
390 fiscal_period: u8,
391 document_date: NaiveDate,
392 created_by: impl Into<String>,
393 ) -> Self {
394 let now = chrono::Utc::now().naive_utc();
395 Self {
396 document_id: document_id.into(),
397 document_type,
398 company_code: company_code.into(),
399 fiscal_year,
400 fiscal_period,
401 document_date,
402 posting_date: None,
403 entry_date: document_date,
404 entry_timestamp: now,
405 status: DocumentStatus::Draft,
406 created_by: created_by.into(),
407 changed_by: None,
408 changed_at: None,
409 created_by_employee_id: None,
410 currency: "USD".to_string(),
411 reference: None,
412 header_text: None,
413 journal_entry_id: None,
414 document_references: Vec::new(),
415 is_fraud: false,
416 fraud_type: None,
417 }
418 }
419
420 pub fn propagate_fraud(
423 &mut self,
424 fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
425 ) -> bool {
426 if let Some(ft) = fraud_map.get(&self.document_id) {
427 self.is_fraud = true;
428 self.fraud_type = Some(*ft);
429 return true;
430 }
431 if let Some(ref je_id) = self.journal_entry_id {
432 if let Some(ft) = fraud_map.get(je_id) {
433 self.is_fraud = true;
434 self.fraud_type = Some(*ft);
435 return true;
436 }
437 }
438 false
439 }
440
441 pub fn with_created_by_employee_id(mut self, employee_id: impl Into<String>) -> Self {
443 self.created_by_employee_id = Some(employee_id.into());
444 self
445 }
446
447 pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
449 self.posting_date = Some(date);
450 self
451 }
452
453 pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
455 self.currency = currency.into();
456 self
457 }
458
459 pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
461 self.reference = Some(reference.into());
462 self
463 }
464
465 pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
467 self.header_text = Some(text.into());
468 self
469 }
470
471 pub fn add_reference(&mut self, reference: DocumentReference) {
473 self.document_references.push(reference);
474 }
475
476 pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
478 self.status = new_status;
479 self.changed_by = Some(user.into());
480 self.changed_at = Some(chrono::Utc::now().naive_utc());
481 }
482
483 pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
485 format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct DocumentLineItem {
492 pub line_number: u16,
494
495 pub material_id: Option<String>,
497
498 pub description: String,
500
501 pub quantity: rust_decimal::Decimal,
503
504 pub uom: String,
506
507 pub unit_price: rust_decimal::Decimal,
509
510 pub net_amount: rust_decimal::Decimal,
512
513 pub tax_amount: rust_decimal::Decimal,
515
516 pub gross_amount: rust_decimal::Decimal,
518
519 pub gl_account: Option<String>,
521
522 pub cost_center: Option<String>,
524
525 pub profit_center: Option<String>,
527
528 pub internal_order: Option<String>,
530
531 pub wbs_element: Option<String>,
533
534 pub delivery_date: Option<NaiveDate>,
536
537 pub plant: Option<String>,
539
540 pub storage_location: Option<String>,
542
543 pub line_text: Option<String>,
545
546 pub is_cancelled: bool,
548}
549
550impl DocumentLineItem {
551 pub fn new(
553 line_number: u16,
554 description: impl Into<String>,
555 quantity: rust_decimal::Decimal,
556 unit_price: rust_decimal::Decimal,
557 ) -> Self {
558 let net_amount = quantity * unit_price;
559 Self {
560 line_number,
561 material_id: None,
562 description: description.into(),
563 quantity,
564 uom: "EA".to_string(),
565 unit_price,
566 net_amount,
567 tax_amount: rust_decimal::Decimal::ZERO,
568 gross_amount: net_amount,
569 gl_account: None,
570 cost_center: None,
571 profit_center: None,
572 internal_order: None,
573 wbs_element: None,
574 delivery_date: None,
575 plant: None,
576 storage_location: None,
577 line_text: None,
578 is_cancelled: false,
579 }
580 }
581
582 pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
584 self.material_id = Some(material_id.into());
585 self
586 }
587
588 pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
590 self.gl_account = Some(account.into());
591 self
592 }
593
594 pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
596 self.cost_center = Some(cost_center.into());
597 self
598 }
599
600 pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
602 self.tax_amount = tax_amount;
603 self.gross_amount = self.net_amount + tax_amount;
604 self
605 }
606
607 pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
609 self.uom = uom.into();
610 self
611 }
612
613 pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
615 self.delivery_date = Some(date);
616 self
617 }
618
619 pub fn recalculate(&mut self) {
621 self.net_amount = self.quantity * self.unit_price;
622 self.gross_amount = self.net_amount + self.tax_amount;
623 }
624}
625
626#[cfg(test)]
627#[allow(clippy::unwrap_used)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn test_document_type_prefix() {
633 assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
634 assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
635 assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
636 }
637
638 #[test]
639 fn test_document_reference() {
640 let reference = DocumentReference::follow_on(
641 DocumentType::PurchaseOrder,
642 "PO-1000-0000000001",
643 DocumentType::GoodsReceipt,
644 "GR-1000-0000000001",
645 "1000",
646 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
647 );
648
649 assert_eq!(reference.reference_type, ReferenceType::FollowOn);
650 assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
651 }
652
653 #[test]
654 fn test_document_header() {
655 let header = DocumentHeader::new(
656 "PO-1000-0000000001",
657 DocumentType::PurchaseOrder,
658 "1000",
659 2024,
660 1,
661 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
662 "JSMITH",
663 )
664 .with_currency("EUR")
665 .with_reference("EXT-REF-123");
666
667 assert_eq!(header.currency, "EUR");
668 assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
669 assert_eq!(header.status, DocumentStatus::Draft);
670 }
671
672 #[test]
673 fn test_document_line_item() {
674 let item = DocumentLineItem::new(
675 1,
676 "Office Supplies",
677 rust_decimal::Decimal::from(10),
678 rust_decimal::Decimal::from(25),
679 )
680 .with_tax(rust_decimal::Decimal::from(25));
681
682 assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
683 assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
684 }
685
686 #[test]
687 fn test_document_status() {
688 assert!(DocumentStatus::Draft.is_editable());
689 assert!(!DocumentStatus::Posted.is_editable());
690 assert!(DocumentStatus::Released.can_cancel());
691 assert!(!DocumentStatus::Cancelled.can_cancel());
692 }
693
694 #[test]
703 fn test_propagate_fraud_via_bare_document_id() {
704 use crate::models::FraudType;
705
706 let mut header = DocumentHeader::new(
707 "PO-2024-000001",
708 DocumentType::PurchaseOrder,
709 "1000",
710 2024,
711 6,
712 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
713 "JSMITH",
714 );
715
716 let raw_reference = "GR:PO-2024-000001";
719 let mut fraud_map = std::collections::HashMap::new();
720 fraud_map.insert(raw_reference.to_string(), FraudType::DuplicatePayment);
721 if let Some((_, bare)) = raw_reference.split_once(':') {
722 fraud_map.insert(bare.to_string(), FraudType::DuplicatePayment);
723 }
724
725 assert!(
726 header.propagate_fraud(&fraud_map),
727 "propagate_fraud should find the bare document_id ({}) in fraud_map",
728 header.document_id,
729 );
730 assert!(header.is_fraud);
731 assert_eq!(header.fraud_type, Some(FraudType::DuplicatePayment));
732 }
733
734 #[test]
738 fn test_propagate_fraud_only_prefixed_form_misses() {
739 use crate::models::FraudType;
740
741 let mut header = DocumentHeader::new(
742 "PAY-2024-000001",
743 DocumentType::ApPayment,
744 "1000",
745 2024,
746 6,
747 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
748 "JSMITH",
749 );
750 let mut fraud_map = std::collections::HashMap::new();
751 fraud_map.insert(
753 "PAY:PAY-2024-000001".to_string(),
754 FraudType::DuplicatePayment,
755 );
756 assert!(
757 !header.propagate_fraud(&fraud_map),
758 "Prefixed-only fraud_map should NOT match bare document_id — this is the bug we fixed"
759 );
760 assert!(!header.is_fraud);
761 }
762}