1use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11use uuid::Uuid;
12
13use super::anomaly::FraudType;
14use super::approval::ApprovalWorkflow;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub enum DocumentRef {
22 PurchaseOrder(String),
24 VendorInvoice(String),
26 CustomerInvoice(String),
28 GoodsReceipt(String),
30 Delivery(String),
32 Payment(String),
34 Receipt(String),
36 Manual,
38}
39
40impl DocumentRef {
41 pub fn parse(reference: &str) -> Option<Self> {
45 if reference.starts_with("PO-") || reference.starts_with("PO ") {
46 Some(Self::PurchaseOrder(reference.to_string()))
47 } else if reference.starts_with("INV-") || reference.starts_with("INV ") {
48 Some(Self::VendorInvoice(reference.to_string()))
49 } else if reference.starts_with("SO-") || reference.starts_with("SO ") {
50 Some(Self::CustomerInvoice(reference.to_string()))
51 } else if reference.starts_with("GR-") || reference.starts_with("GR ") {
52 Some(Self::GoodsReceipt(reference.to_string()))
53 } else if reference.starts_with("PAY-") || reference.starts_with("PAY ") {
54 Some(Self::Payment(reference.to_string()))
55 } else {
56 None
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum TransactionSource {
68 #[default]
70 Manual,
71 Automated,
73 Recurring,
75 Reversal,
77 Adjustment,
79 Statistical,
81}
82
83impl std::fmt::Display for TransactionSource {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 Self::Manual => write!(f, "manual"),
87 Self::Automated => write!(f, "automated"),
88 Self::Recurring => write!(f, "recurring"),
89 Self::Reversal => write!(f, "reversal"),
90 Self::Adjustment => write!(f, "adjustment"),
91 Self::Statistical => write!(f, "statistical"),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
104#[serde(rename_all = "UPPERCASE")]
105pub enum BusinessProcess {
106 O2C,
108 P2P,
110 #[default]
112 R2R,
113 H2R,
115 A2R,
117 S2C,
119 #[serde(rename = "MFG")]
121 Mfg,
122 #[serde(rename = "BANK")]
124 Bank,
125 #[serde(rename = "AUDIT")]
127 Audit,
128 Treasury,
130 Tax,
132 Intercompany,
134 #[serde(rename = "PROJECT")]
136 ProjectAccounting,
137 #[serde(rename = "ESG")]
139 Esg,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct DocumentType {
147 pub code: String,
149 pub description: String,
151 pub business_process: BusinessProcess,
153 pub is_reversal: bool,
155}
156
157impl DocumentType {
158 pub fn gl_account() -> Self {
160 Self {
161 code: "SA".to_string(),
162 description: "G/L Account Document".to_string(),
163 business_process: BusinessProcess::R2R,
164 is_reversal: false,
165 }
166 }
167
168 pub fn vendor_invoice() -> Self {
170 Self {
171 code: "KR".to_string(),
172 description: "Vendor Invoice".to_string(),
173 business_process: BusinessProcess::P2P,
174 is_reversal: false,
175 }
176 }
177
178 pub fn customer_invoice() -> Self {
180 Self {
181 code: "DR".to_string(),
182 description: "Customer Invoice".to_string(),
183 business_process: BusinessProcess::O2C,
184 is_reversal: false,
185 }
186 }
187
188 pub fn vendor_payment() -> Self {
190 Self {
191 code: "KZ".to_string(),
192 description: "Vendor Payment".to_string(),
193 business_process: BusinessProcess::P2P,
194 is_reversal: false,
195 }
196 }
197
198 pub fn customer_payment() -> Self {
200 Self {
201 code: "DZ".to_string(),
202 description: "Customer Payment".to_string(),
203 business_process: BusinessProcess::O2C,
204 is_reversal: false,
205 }
206 }
207
208 pub fn asset_posting() -> Self {
210 Self {
211 code: "AA".to_string(),
212 description: "Asset Posting".to_string(),
213 business_process: BusinessProcess::A2R,
214 is_reversal: false,
215 }
216 }
217
218 pub fn payroll() -> Self {
220 Self {
221 code: "PR".to_string(),
222 description: "Payroll Document".to_string(),
223 business_process: BusinessProcess::H2R,
224 is_reversal: false,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct JournalEntryHeader {
235 pub document_id: Uuid,
237
238 pub company_code: String,
240
241 pub fiscal_year: u16,
243
244 pub fiscal_period: u8,
246
247 pub posting_date: NaiveDate,
249
250 pub document_date: NaiveDate,
252
253 #[serde(with = "crate::serde_timestamp::utc")]
255 pub created_at: DateTime<Utc>,
256
257 pub document_type: String,
259
260 pub currency: String,
262
263 #[serde(with = "crate::serde_decimal")]
265 pub exchange_rate: Decimal,
266
267 pub reference: Option<String>,
269
270 pub header_text: Option<String>,
272
273 pub created_by: String,
275
276 pub user_persona: String,
278
279 pub source: TransactionSource,
281
282 pub business_process: Option<BusinessProcess>,
284
285 pub ledger: String,
287
288 pub is_fraud: bool,
290
291 pub fraud_type: Option<FraudType>,
293
294 #[serde(default)]
297 pub is_anomaly: bool,
298
299 #[serde(default)]
301 pub anomaly_id: Option<String>,
302
303 #[serde(default)]
305 pub anomaly_type: Option<String>,
306
307 pub batch_id: Option<Uuid>,
309
310 #[serde(default)]
314 pub is_manual: bool,
315
316 #[serde(default)]
319 pub is_post_close: bool,
320
321 #[serde(default)]
324 pub source_system: String,
325
326 #[serde(default, with = "crate::serde_timestamp::naive::option")]
330 pub created_date: Option<NaiveDateTime>,
331
332 #[serde(default)]
335 pub control_ids: Vec<String>,
336
337 #[serde(default)]
339 pub sox_relevant: bool,
340
341 #[serde(default)]
343 pub control_status: super::internal_control::ControlStatus,
344
345 #[serde(default)]
347 pub sod_violation: bool,
348
349 #[serde(default)]
351 pub sod_conflict_type: Option<super::sod::SodConflictType>,
352
353 #[serde(default)]
355 pub is_elimination: bool,
356
357 #[serde(default)]
360 pub approval_workflow: Option<ApprovalWorkflow>,
361
362 #[serde(default)]
365 pub source_document: Option<DocumentRef>,
366 #[serde(default)]
368 pub approved_by: Option<String>,
369 #[serde(default)]
371 pub approval_date: Option<NaiveDate>,
372
373 #[serde(default)]
376 pub ocpm_event_ids: Vec<Uuid>,
377
378 #[serde(default)]
380 pub ocpm_object_ids: Vec<Uuid>,
381
382 #[serde(default)]
384 pub ocpm_case_id: Option<Uuid>,
385}
386
387impl JournalEntryHeader {
388 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
390 Self {
391 document_id: Uuid::now_v7(),
392 company_code,
393 fiscal_year: posting_date.year() as u16,
394 fiscal_period: posting_date.month() as u8,
395 posting_date,
396 document_date: posting_date,
397 created_at: Utc::now(),
398 document_type: "SA".to_string(),
399 currency: "USD".to_string(),
400 exchange_rate: Decimal::ONE,
401 reference: None,
402 header_text: None,
403 created_by: "SYSTEM".to_string(),
404 user_persona: "automated_system".to_string(),
405 source: TransactionSource::Automated,
406 business_process: Some(BusinessProcess::R2R),
407 ledger: "0L".to_string(),
408 is_fraud: false,
409 fraud_type: None,
410 is_anomaly: false,
412 anomaly_id: None,
413 anomaly_type: None,
414 batch_id: None,
415 is_manual: false,
417 is_post_close: false,
418 source_system: String::new(),
419 created_date: None,
420 control_ids: Vec::new(),
422 sox_relevant: false,
423 control_status: super::internal_control::ControlStatus::default(),
424 sod_violation: false,
425 sod_conflict_type: None,
426 is_elimination: false,
428 approval_workflow: None,
430 source_document: None,
432 approved_by: None,
433 approval_date: None,
434 ocpm_event_ids: Vec::new(),
436 ocpm_object_ids: Vec::new(),
437 ocpm_case_id: None,
438 }
439 }
440
441 pub fn with_deterministic_id(
446 company_code: String,
447 posting_date: NaiveDate,
448 document_id: Uuid,
449 ) -> Self {
450 Self {
451 document_id,
452 company_code,
453 fiscal_year: posting_date.year() as u16,
454 fiscal_period: posting_date.month() as u8,
455 posting_date,
456 document_date: posting_date,
457 created_at: Utc::now(),
458 document_type: "SA".to_string(),
459 currency: "USD".to_string(),
460 exchange_rate: Decimal::ONE,
461 reference: None,
462 header_text: None,
463 created_by: "SYSTEM".to_string(),
464 user_persona: "automated_system".to_string(),
465 source: TransactionSource::Automated,
466 business_process: Some(BusinessProcess::R2R),
467 ledger: "0L".to_string(),
468 is_fraud: false,
469 fraud_type: None,
470 is_anomaly: false,
472 anomaly_id: None,
473 anomaly_type: None,
474 batch_id: None,
475 is_manual: false,
477 is_post_close: false,
478 source_system: String::new(),
479 created_date: None,
480 control_ids: Vec::new(),
482 sox_relevant: false,
483 control_status: super::internal_control::ControlStatus::default(),
484 sod_violation: false,
485 sod_conflict_type: None,
486 is_elimination: false,
488 approval_workflow: None,
490 source_document: None,
492 approved_by: None,
493 approval_date: None,
494 ocpm_event_ids: Vec::new(),
496 ocpm_object_ids: Vec::new(),
497 ocpm_case_id: None,
498 }
499 }
500}
501
502use chrono::Datelike;
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct JournalEntryLine {
510 pub document_id: Uuid,
512
513 pub line_number: u32,
515
516 pub gl_account: String,
518
519 #[serde(default)]
521 pub account_code: String,
522
523 #[serde(default)]
525 pub account_description: Option<String>,
526
527 #[serde(with = "crate::serde_decimal")]
529 pub debit_amount: Decimal,
530
531 #[serde(with = "crate::serde_decimal")]
533 pub credit_amount: Decimal,
534
535 #[serde(with = "crate::serde_decimal")]
537 pub local_amount: Decimal,
538
539 #[serde(default, with = "crate::serde_decimal::option")]
541 pub group_amount: Option<Decimal>,
542
543 pub cost_center: Option<String>,
545
546 pub profit_center: Option<String>,
548
549 pub segment: Option<String>,
551
552 pub functional_area: Option<String>,
554
555 pub line_text: Option<String>,
557
558 #[serde(default)]
560 pub text: Option<String>,
561
562 #[serde(default)]
564 pub reference: Option<String>,
565
566 #[serde(default)]
568 pub value_date: Option<NaiveDate>,
569
570 pub tax_code: Option<String>,
572
573 #[serde(default, with = "crate::serde_decimal::option")]
575 pub tax_amount: Option<Decimal>,
576
577 pub assignment: Option<String>,
579
580 pub offsetting_account: Option<String>,
582
583 pub is_suspense: bool,
585
586 pub trading_partner: Option<String>,
588
589 #[serde(default, with = "crate::serde_decimal::option")]
591 pub quantity: Option<Decimal>,
592
593 pub unit_of_measure: Option<String>,
595
596 #[serde(default)]
598 pub unit: Option<String>,
599
600 #[serde(default)]
602 pub project_code: Option<String>,
603
604 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub auxiliary_account_number: Option<String>,
608
609 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub auxiliary_account_label: Option<String>,
613
614 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub lettrage: Option<String>,
618
619 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub lettrage_date: Option<NaiveDate>,
623}
624
625impl JournalEntryLine {
626 #[inline]
628 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
629 Self {
630 document_id,
631 line_number,
632 gl_account: gl_account.clone(),
633 account_code: gl_account,
634 account_description: None,
635 debit_amount: amount,
636 credit_amount: Decimal::ZERO,
637 local_amount: amount,
638 group_amount: None,
639 cost_center: None,
640 profit_center: None,
641 segment: None,
642 functional_area: None,
643 line_text: None,
644 text: None,
645 reference: None,
646 value_date: None,
647 tax_code: None,
648 tax_amount: None,
649 assignment: None,
650 offsetting_account: None,
651 is_suspense: false,
652 trading_partner: None,
653 quantity: None,
654 unit_of_measure: None,
655 unit: None,
656 project_code: None,
657 auxiliary_account_number: None,
658 auxiliary_account_label: None,
659 lettrage: None,
660 lettrage_date: None,
661 }
662 }
663
664 #[inline]
666 pub fn credit(
667 document_id: Uuid,
668 line_number: u32,
669 gl_account: String,
670 amount: Decimal,
671 ) -> Self {
672 Self {
673 document_id,
674 line_number,
675 gl_account: gl_account.clone(),
676 account_code: gl_account,
677 account_description: None,
678 debit_amount: Decimal::ZERO,
679 credit_amount: amount,
680 local_amount: -amount,
681 group_amount: None,
682 cost_center: None,
683 profit_center: None,
684 segment: None,
685 functional_area: None,
686 line_text: None,
687 text: None,
688 reference: None,
689 value_date: None,
690 tax_code: None,
691 tax_amount: None,
692 assignment: None,
693 offsetting_account: None,
694 is_suspense: false,
695 trading_partner: None,
696 quantity: None,
697 unit_of_measure: None,
698 unit: None,
699 project_code: None,
700 auxiliary_account_number: None,
701 auxiliary_account_label: None,
702 lettrage: None,
703 lettrage_date: None,
704 }
705 }
706
707 #[inline]
709 pub fn is_debit(&self) -> bool {
710 self.debit_amount > Decimal::ZERO
711 }
712
713 #[inline]
715 pub fn is_credit(&self) -> bool {
716 self.credit_amount > Decimal::ZERO
717 }
718
719 #[inline]
721 pub fn signed_amount(&self) -> Decimal {
722 self.debit_amount - self.credit_amount
723 }
724
725 #[allow(clippy::misnamed_getters)]
729 pub fn account_code(&self) -> &str {
730 &self.gl_account
731 }
732
733 pub fn account_description(&self) -> &str {
735 ""
737 }
738}
739
740impl Default for JournalEntryLine {
741 fn default() -> Self {
742 Self {
743 document_id: Uuid::nil(),
744 line_number: 0,
745 gl_account: String::new(),
746 account_code: String::new(),
747 account_description: None,
748 debit_amount: Decimal::ZERO,
749 credit_amount: Decimal::ZERO,
750 local_amount: Decimal::ZERO,
751 group_amount: None,
752 cost_center: None,
753 profit_center: None,
754 segment: None,
755 functional_area: None,
756 line_text: None,
757 text: None,
758 reference: None,
759 value_date: None,
760 tax_code: None,
761 tax_amount: None,
762 assignment: None,
763 offsetting_account: None,
764 is_suspense: false,
765 trading_partner: None,
766 quantity: None,
767 unit_of_measure: None,
768 unit: None,
769 project_code: None,
770 auxiliary_account_number: None,
771 auxiliary_account_label: None,
772 lettrage: None,
773 lettrage_date: None,
774 }
775 }
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct JournalEntry {
789 pub header: JournalEntryHeader,
791 pub lines: SmallVec<[JournalEntryLine; 4]>,
794}
795
796impl JournalEntry {
797 pub fn new(header: JournalEntryHeader) -> Self {
799 Self {
800 header,
801 lines: SmallVec::new(),
802 }
803 }
804
805 pub fn new_simple(
811 document_number: String,
812 company_code: String,
813 posting_date: NaiveDate,
814 description: String,
815 ) -> Self {
816 let mut header = JournalEntryHeader::new(company_code, posting_date);
817 header.header_text = Some(description);
818 header.reference = Some(document_number);
819 Self {
820 header,
821 lines: SmallVec::new(),
822 }
823 }
824
825 #[inline]
829 pub fn add_line(&mut self, mut line: JournalEntryLine) {
830 line.document_id = self.header.document_id;
831 self.lines.push(line);
832 }
833
834 pub fn total_debit(&self) -> Decimal {
836 self.lines.iter().map(|l| l.debit_amount).sum()
837 }
838
839 pub fn total_credit(&self) -> Decimal {
841 self.lines.iter().map(|l| l.credit_amount).sum()
842 }
843
844 pub fn is_balanced(&self) -> bool {
846 self.total_debit() == self.total_credit()
847 }
848
849 pub fn balance_difference(&self) -> Decimal {
851 self.total_debit() - self.total_credit()
852 }
853
854 pub fn line_count(&self) -> usize {
856 self.lines.len()
857 }
858
859 pub fn has_even_line_count(&self) -> bool {
861 self.lines.len().is_multiple_of(2)
862 }
863
864 pub fn debit_credit_counts(&self) -> (usize, usize) {
866 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
867 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
868 (debits, credits)
869 }
870
871 pub fn has_equal_debit_credit_counts(&self) -> bool {
873 let (d, c) = self.debit_credit_counts();
874 d == c
875 }
876
877 pub fn unique_accounts(&self) -> Vec<&str> {
879 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
880 accounts.sort();
881 accounts.dedup();
882 accounts
883 }
884
885 pub fn has_suspense_posting(&self) -> bool {
887 self.lines.iter().any(|l| l.is_suspense)
888 }
889
890 pub fn company_code(&self) -> &str {
894 &self.header.company_code
895 }
896
897 pub fn document_number(&self) -> String {
899 self.header.document_id.to_string()
900 }
901
902 pub fn posting_date(&self) -> NaiveDate {
904 self.header.posting_date
905 }
906
907 pub fn document_date(&self) -> NaiveDate {
909 self.header.document_date
910 }
911
912 pub fn fiscal_year(&self) -> u16 {
914 self.header.fiscal_year
915 }
916
917 pub fn fiscal_period(&self) -> u8 {
919 self.header.fiscal_period
920 }
921
922 pub fn currency(&self) -> &str {
924 &self.header.currency
925 }
926
927 pub fn is_fraud(&self) -> bool {
929 self.header.is_fraud
930 }
931
932 pub fn has_sod_violation(&self) -> bool {
934 self.header.sod_violation
935 }
936
937 pub fn description(&self) -> Option<&str> {
939 self.header.header_text.as_deref()
940 }
941
942 pub fn set_description(&mut self, description: String) {
944 self.header.header_text = Some(description);
945 }
946}
947
948#[cfg(test)]
949#[allow(clippy::unwrap_used)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn test_balanced_entry() {
955 let header = JournalEntryHeader::new(
956 "1000".to_string(),
957 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
958 );
959 let mut entry = JournalEntry::new(header);
960
961 entry.add_line(JournalEntryLine::debit(
962 entry.header.document_id,
963 1,
964 "100000".to_string(),
965 Decimal::from(1000),
966 ));
967 entry.add_line(JournalEntryLine::credit(
968 entry.header.document_id,
969 2,
970 "200000".to_string(),
971 Decimal::from(1000),
972 ));
973
974 assert!(entry.is_balanced());
975 assert_eq!(entry.line_count(), 2);
976 assert!(entry.has_even_line_count());
977 assert!(entry.has_equal_debit_credit_counts());
978 }
979
980 #[test]
981 fn test_unbalanced_entry() {
982 let header = JournalEntryHeader::new(
983 "1000".to_string(),
984 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
985 );
986 let mut entry = JournalEntry::new(header);
987
988 entry.add_line(JournalEntryLine::debit(
989 entry.header.document_id,
990 1,
991 "100000".to_string(),
992 Decimal::from(1000),
993 ));
994 entry.add_line(JournalEntryLine::credit(
995 entry.header.document_id,
996 2,
997 "200000".to_string(),
998 Decimal::from(500),
999 ));
1000
1001 assert!(!entry.is_balanced());
1002 assert_eq!(entry.balance_difference(), Decimal::from(500));
1003 }
1004}