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 pub entry_timestamp: NaiveDateTime,
333
334 pub status: DocumentStatus,
336
337 pub created_by: String,
339
340 pub changed_by: Option<String>,
342
343 pub changed_at: Option<NaiveDateTime>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub created_by_employee_id: Option<String>,
355
356 pub currency: String,
358
359 pub reference: Option<String>,
361
362 pub header_text: Option<String>,
364
365 pub journal_entry_id: Option<String>,
367
368 pub document_references: Vec<DocumentReference>,
370}
371
372impl DocumentHeader {
373 pub fn new(
375 document_id: impl Into<String>,
376 document_type: DocumentType,
377 company_code: impl Into<String>,
378 fiscal_year: u16,
379 fiscal_period: u8,
380 document_date: NaiveDate,
381 created_by: impl Into<String>,
382 ) -> Self {
383 let now = chrono::Utc::now().naive_utc();
384 Self {
385 document_id: document_id.into(),
386 document_type,
387 company_code: company_code.into(),
388 fiscal_year,
389 fiscal_period,
390 document_date,
391 posting_date: None,
392 entry_date: document_date,
393 entry_timestamp: now,
394 status: DocumentStatus::Draft,
395 created_by: created_by.into(),
396 changed_by: None,
397 changed_at: None,
398 created_by_employee_id: None,
399 currency: "USD".to_string(),
400 reference: None,
401 header_text: None,
402 journal_entry_id: None,
403 document_references: Vec::new(),
404 }
405 }
406
407 pub fn with_created_by_employee_id(mut self, employee_id: impl Into<String>) -> Self {
409 self.created_by_employee_id = Some(employee_id.into());
410 self
411 }
412
413 pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
415 self.posting_date = Some(date);
416 self
417 }
418
419 pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
421 self.currency = currency.into();
422 self
423 }
424
425 pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
427 self.reference = Some(reference.into());
428 self
429 }
430
431 pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
433 self.header_text = Some(text.into());
434 self
435 }
436
437 pub fn add_reference(&mut self, reference: DocumentReference) {
439 self.document_references.push(reference);
440 }
441
442 pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
444 self.status = new_status;
445 self.changed_by = Some(user.into());
446 self.changed_at = Some(chrono::Utc::now().naive_utc());
447 }
448
449 pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
451 format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
452 }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct DocumentLineItem {
458 pub line_number: u16,
460
461 pub material_id: Option<String>,
463
464 pub description: String,
466
467 pub quantity: rust_decimal::Decimal,
469
470 pub uom: String,
472
473 pub unit_price: rust_decimal::Decimal,
475
476 pub net_amount: rust_decimal::Decimal,
478
479 pub tax_amount: rust_decimal::Decimal,
481
482 pub gross_amount: rust_decimal::Decimal,
484
485 pub gl_account: Option<String>,
487
488 pub cost_center: Option<String>,
490
491 pub profit_center: Option<String>,
493
494 pub internal_order: Option<String>,
496
497 pub wbs_element: Option<String>,
499
500 pub delivery_date: Option<NaiveDate>,
502
503 pub plant: Option<String>,
505
506 pub storage_location: Option<String>,
508
509 pub line_text: Option<String>,
511
512 pub is_cancelled: bool,
514}
515
516impl DocumentLineItem {
517 pub fn new(
519 line_number: u16,
520 description: impl Into<String>,
521 quantity: rust_decimal::Decimal,
522 unit_price: rust_decimal::Decimal,
523 ) -> Self {
524 let net_amount = quantity * unit_price;
525 Self {
526 line_number,
527 material_id: None,
528 description: description.into(),
529 quantity,
530 uom: "EA".to_string(),
531 unit_price,
532 net_amount,
533 tax_amount: rust_decimal::Decimal::ZERO,
534 gross_amount: net_amount,
535 gl_account: None,
536 cost_center: None,
537 profit_center: None,
538 internal_order: None,
539 wbs_element: None,
540 delivery_date: None,
541 plant: None,
542 storage_location: None,
543 line_text: None,
544 is_cancelled: false,
545 }
546 }
547
548 pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
550 self.material_id = Some(material_id.into());
551 self
552 }
553
554 pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
556 self.gl_account = Some(account.into());
557 self
558 }
559
560 pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
562 self.cost_center = Some(cost_center.into());
563 self
564 }
565
566 pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
568 self.tax_amount = tax_amount;
569 self.gross_amount = self.net_amount + tax_amount;
570 self
571 }
572
573 pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
575 self.uom = uom.into();
576 self
577 }
578
579 pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
581 self.delivery_date = Some(date);
582 self
583 }
584
585 pub fn recalculate(&mut self) {
587 self.net_amount = self.quantity * self.unit_price;
588 self.gross_amount = self.net_amount + self.tax_amount;
589 }
590}
591
592#[cfg(test)]
593#[allow(clippy::unwrap_used)]
594mod tests {
595 use super::*;
596
597 #[test]
598 fn test_document_type_prefix() {
599 assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
600 assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
601 assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
602 }
603
604 #[test]
605 fn test_document_reference() {
606 let reference = DocumentReference::follow_on(
607 DocumentType::PurchaseOrder,
608 "PO-1000-0000000001",
609 DocumentType::GoodsReceipt,
610 "GR-1000-0000000001",
611 "1000",
612 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
613 );
614
615 assert_eq!(reference.reference_type, ReferenceType::FollowOn);
616 assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
617 }
618
619 #[test]
620 fn test_document_header() {
621 let header = DocumentHeader::new(
622 "PO-1000-0000000001",
623 DocumentType::PurchaseOrder,
624 "1000",
625 2024,
626 1,
627 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
628 "JSMITH",
629 )
630 .with_currency("EUR")
631 .with_reference("EXT-REF-123");
632
633 assert_eq!(header.currency, "EUR");
634 assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
635 assert_eq!(header.status, DocumentStatus::Draft);
636 }
637
638 #[test]
639 fn test_document_line_item() {
640 let item = DocumentLineItem::new(
641 1,
642 "Office Supplies",
643 rust_decimal::Decimal::from(10),
644 rust_decimal::Decimal::from(25),
645 )
646 .with_tax(rust_decimal::Decimal::from(25));
647
648 assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
649 assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
650 }
651
652 #[test]
653 fn test_document_status() {
654 assert!(DocumentStatus::Draft.is_editable());
655 assert!(!DocumentStatus::Posted.is_editable());
656 assert!(DocumentStatus::Released.can_cancel());
657 assert!(!DocumentStatus::Cancelled.can_cancel());
658 }
659}