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 pub currency: String,
348
349 pub reference: Option<String>,
351
352 pub header_text: Option<String>,
354
355 pub journal_entry_id: Option<String>,
357
358 pub document_references: Vec<DocumentReference>,
360}
361
362impl DocumentHeader {
363 pub fn new(
365 document_id: impl Into<String>,
366 document_type: DocumentType,
367 company_code: impl Into<String>,
368 fiscal_year: u16,
369 fiscal_period: u8,
370 document_date: NaiveDate,
371 created_by: impl Into<String>,
372 ) -> Self {
373 let now = chrono::Utc::now().naive_utc();
374 Self {
375 document_id: document_id.into(),
376 document_type,
377 company_code: company_code.into(),
378 fiscal_year,
379 fiscal_period,
380 document_date,
381 posting_date: None,
382 entry_date: document_date,
383 entry_timestamp: now,
384 status: DocumentStatus::Draft,
385 created_by: created_by.into(),
386 changed_by: None,
387 changed_at: None,
388 currency: "USD".to_string(),
389 reference: None,
390 header_text: None,
391 journal_entry_id: None,
392 document_references: Vec::new(),
393 }
394 }
395
396 pub fn with_posting_date(mut self, date: NaiveDate) -> Self {
398 self.posting_date = Some(date);
399 self
400 }
401
402 pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
404 self.currency = currency.into();
405 self
406 }
407
408 pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
410 self.reference = Some(reference.into());
411 self
412 }
413
414 pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
416 self.header_text = Some(text.into());
417 self
418 }
419
420 pub fn add_reference(&mut self, reference: DocumentReference) {
422 self.document_references.push(reference);
423 }
424
425 pub fn update_status(&mut self, new_status: DocumentStatus, user: impl Into<String>) {
427 self.status = new_status;
428 self.changed_by = Some(user.into());
429 self.changed_at = Some(chrono::Utc::now().naive_utc());
430 }
431
432 pub fn generate_id(doc_type: DocumentType, company_code: &str, sequence: u64) -> String {
434 format!("{}-{}-{:010}", doc_type.prefix(), company_code, sequence)
435 }
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct DocumentLineItem {
441 pub line_number: u16,
443
444 pub material_id: Option<String>,
446
447 pub description: String,
449
450 pub quantity: rust_decimal::Decimal,
452
453 pub uom: String,
455
456 pub unit_price: rust_decimal::Decimal,
458
459 pub net_amount: rust_decimal::Decimal,
461
462 pub tax_amount: rust_decimal::Decimal,
464
465 pub gross_amount: rust_decimal::Decimal,
467
468 pub gl_account: Option<String>,
470
471 pub cost_center: Option<String>,
473
474 pub profit_center: Option<String>,
476
477 pub internal_order: Option<String>,
479
480 pub wbs_element: Option<String>,
482
483 pub delivery_date: Option<NaiveDate>,
485
486 pub plant: Option<String>,
488
489 pub storage_location: Option<String>,
491
492 pub line_text: Option<String>,
494
495 pub is_cancelled: bool,
497}
498
499impl DocumentLineItem {
500 pub fn new(
502 line_number: u16,
503 description: impl Into<String>,
504 quantity: rust_decimal::Decimal,
505 unit_price: rust_decimal::Decimal,
506 ) -> Self {
507 let net_amount = quantity * unit_price;
508 Self {
509 line_number,
510 material_id: None,
511 description: description.into(),
512 quantity,
513 uom: "EA".to_string(),
514 unit_price,
515 net_amount,
516 tax_amount: rust_decimal::Decimal::ZERO,
517 gross_amount: net_amount,
518 gl_account: None,
519 cost_center: None,
520 profit_center: None,
521 internal_order: None,
522 wbs_element: None,
523 delivery_date: None,
524 plant: None,
525 storage_location: None,
526 line_text: None,
527 is_cancelled: false,
528 }
529 }
530
531 pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
533 self.material_id = Some(material_id.into());
534 self
535 }
536
537 pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
539 self.gl_account = Some(account.into());
540 self
541 }
542
543 pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
545 self.cost_center = Some(cost_center.into());
546 self
547 }
548
549 pub fn with_tax(mut self, tax_amount: rust_decimal::Decimal) -> Self {
551 self.tax_amount = tax_amount;
552 self.gross_amount = self.net_amount + tax_amount;
553 self
554 }
555
556 pub fn with_uom(mut self, uom: impl Into<String>) -> Self {
558 self.uom = uom.into();
559 self
560 }
561
562 pub fn with_delivery_date(mut self, date: NaiveDate) -> Self {
564 self.delivery_date = Some(date);
565 self
566 }
567
568 pub fn recalculate(&mut self) {
570 self.net_amount = self.quantity * self.unit_price;
571 self.gross_amount = self.net_amount + self.tax_amount;
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_document_type_prefix() {
581 assert_eq!(DocumentType::PurchaseOrder.prefix(), "PO");
582 assert_eq!(DocumentType::VendorInvoice.prefix(), "VI");
583 assert_eq!(DocumentType::CustomerInvoice.prefix(), "CI");
584 }
585
586 #[test]
587 fn test_document_reference() {
588 let reference = DocumentReference::follow_on(
589 DocumentType::PurchaseOrder,
590 "PO-1000-0000000001",
591 DocumentType::GoodsReceipt,
592 "GR-1000-0000000001",
593 "1000",
594 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
595 );
596
597 assert_eq!(reference.reference_type, ReferenceType::FollowOn);
598 assert_eq!(reference.source_doc_type, DocumentType::PurchaseOrder);
599 }
600
601 #[test]
602 fn test_document_header() {
603 let header = DocumentHeader::new(
604 "PO-1000-0000000001",
605 DocumentType::PurchaseOrder,
606 "1000",
607 2024,
608 1,
609 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
610 "JSMITH",
611 )
612 .with_currency("EUR")
613 .with_reference("EXT-REF-123");
614
615 assert_eq!(header.currency, "EUR");
616 assert_eq!(header.reference, Some("EXT-REF-123".to_string()));
617 assert_eq!(header.status, DocumentStatus::Draft);
618 }
619
620 #[test]
621 fn test_document_line_item() {
622 let item = DocumentLineItem::new(
623 1,
624 "Office Supplies",
625 rust_decimal::Decimal::from(10),
626 rust_decimal::Decimal::from(25),
627 )
628 .with_tax(rust_decimal::Decimal::from(25));
629
630 assert_eq!(item.net_amount, rust_decimal::Decimal::from(250));
631 assert_eq!(item.gross_amount, rust_decimal::Decimal::from(275));
632 }
633
634 #[test]
635 fn test_document_status() {
636 assert!(DocumentStatus::Draft.is_editable());
637 assert!(!DocumentStatus::Posted.is_editable());
638 assert!(DocumentStatus::Released.can_cancel());
639 assert!(!DocumentStatus::Cancelled.can_cancel());
640 }
641}