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 pub fn document_id(&self) -> Option<&str> {
62 match self {
63 Self::PurchaseOrder(id)
64 | Self::VendorInvoice(id)
65 | Self::CustomerInvoice(id)
66 | Self::GoodsReceipt(id)
67 | Self::Delivery(id)
68 | Self::Payment(id)
69 | Self::Receipt(id) => Some(id.as_str()),
70 Self::Manual => None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum TransactionSource {
82 #[default]
84 Manual,
85 Automated,
87 Recurring,
89 Reversal,
91 Adjustment,
93 Statistical,
95}
96
97impl std::fmt::Display for TransactionSource {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 Self::Manual => write!(f, "manual"),
101 Self::Automated => write!(f, "automated"),
102 Self::Recurring => write!(f, "recurring"),
103 Self::Reversal => write!(f, "reversal"),
104 Self::Adjustment => write!(f, "adjustment"),
105 Self::Statistical => write!(f, "statistical"),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
118#[serde(rename_all = "UPPERCASE")]
119pub enum BusinessProcess {
120 O2C,
122 P2P,
124 #[default]
126 R2R,
127 H2R,
129 A2R,
131 S2C,
133 #[serde(rename = "MFG")]
135 Mfg,
136 #[serde(rename = "BANK")]
138 Bank,
139 #[serde(rename = "AUDIT")]
141 Audit,
142 Treasury,
144 Tax,
146 Intercompany,
148 #[serde(rename = "PROJECT")]
150 ProjectAccounting,
151 #[serde(rename = "ESG")]
153 Esg,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
160pub struct DocumentType {
161 pub code: String,
163 pub description: String,
165 pub business_process: BusinessProcess,
167 pub is_reversal: bool,
169}
170
171impl DocumentType {
172 pub fn gl_account() -> Self {
174 Self {
175 code: "SA".to_string(),
176 description: "G/L Account Document".to_string(),
177 business_process: BusinessProcess::R2R,
178 is_reversal: false,
179 }
180 }
181
182 pub fn vendor_invoice() -> Self {
184 Self {
185 code: "KR".to_string(),
186 description: "Vendor Invoice".to_string(),
187 business_process: BusinessProcess::P2P,
188 is_reversal: false,
189 }
190 }
191
192 pub fn customer_invoice() -> Self {
194 Self {
195 code: "DR".to_string(),
196 description: "Customer Invoice".to_string(),
197 business_process: BusinessProcess::O2C,
198 is_reversal: false,
199 }
200 }
201
202 pub fn vendor_payment() -> Self {
204 Self {
205 code: "KZ".to_string(),
206 description: "Vendor Payment".to_string(),
207 business_process: BusinessProcess::P2P,
208 is_reversal: false,
209 }
210 }
211
212 pub fn customer_payment() -> Self {
214 Self {
215 code: "DZ".to_string(),
216 description: "Customer Payment".to_string(),
217 business_process: BusinessProcess::O2C,
218 is_reversal: false,
219 }
220 }
221
222 pub fn asset_posting() -> Self {
224 Self {
225 code: "AA".to_string(),
226 description: "Asset Posting".to_string(),
227 business_process: BusinessProcess::A2R,
228 is_reversal: false,
229 }
230 }
231
232 pub fn payroll() -> Self {
234 Self {
235 code: "PR".to_string(),
236 description: "Payroll Document".to_string(),
237 business_process: BusinessProcess::H2R,
238 is_reversal: false,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct JournalEntryHeader {
249 pub document_id: Uuid,
251
252 pub company_code: String,
254
255 pub fiscal_year: u16,
257
258 pub fiscal_period: u8,
260
261 pub posting_date: NaiveDate,
263
264 pub document_date: NaiveDate,
266
267 #[serde(with = "crate::serde_timestamp::utc")]
269 pub created_at: DateTime<Utc>,
270
271 pub document_type: String,
273
274 pub currency: String,
276
277 #[serde(with = "crate::serde_decimal")]
279 pub exchange_rate: Decimal,
280
281 pub reference: Option<String>,
283
284 pub header_text: Option<String>,
286
287 pub created_by: String,
289
290 pub user_persona: String,
292
293 pub source: TransactionSource,
295
296 pub business_process: Option<BusinessProcess>,
298
299 pub ledger: String,
301
302 pub is_fraud: bool,
304
305 pub fraud_type: Option<FraudType>,
307
308 #[serde(default)]
313 pub is_fraud_propagated: bool,
314
315 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub fraud_source_document_id: Option<String>,
319
320 #[serde(default)]
323 pub is_anomaly: bool,
324
325 #[serde(default)]
327 pub anomaly_id: Option<String>,
328
329 #[serde(default)]
331 pub anomaly_type: Option<String>,
332
333 pub batch_id: Option<Uuid>,
335
336 #[serde(default)]
340 pub is_manual: bool,
341
342 #[serde(default)]
345 pub is_post_close: bool,
346
347 #[serde(default)]
350 pub source_system: String,
351
352 #[serde(default, with = "crate::serde_timestamp::naive::option")]
356 pub created_date: Option<NaiveDateTime>,
357
358 #[serde(default)]
361 pub control_ids: Vec<String>,
362
363 #[serde(default)]
365 pub sox_relevant: bool,
366
367 #[serde(default)]
369 pub control_status: super::internal_control::ControlStatus,
370
371 #[serde(default)]
373 pub sod_violation: bool,
374
375 #[serde(default)]
377 pub sod_conflict_type: Option<super::sod::SodConflictType>,
378
379 #[serde(default)]
381 pub is_elimination: bool,
382
383 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub ic_pair_id: Option<crate::models::IcPairId>,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub ic_partner_entity: Option<String>,
394
395 #[serde(default)]
398 pub approval_workflow: Option<ApprovalWorkflow>,
399
400 #[serde(default)]
403 pub source_document: Option<DocumentRef>,
404 #[serde(default)]
406 pub approved_by: Option<String>,
407 #[serde(default)]
409 pub approval_date: Option<NaiveDate>,
410
411 #[serde(default)]
414 pub ocpm_event_ids: Vec<Uuid>,
415
416 #[serde(default)]
418 pub ocpm_object_ids: Vec<Uuid>,
419
420 #[serde(default)]
422 pub ocpm_case_id: Option<Uuid>,
423}
424
425impl JournalEntryHeader {
426 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
428 Self {
429 document_id: Uuid::now_v7(),
430 company_code,
431 fiscal_year: posting_date.year() as u16,
432 fiscal_period: posting_date.month() as u8,
433 posting_date,
434 document_date: posting_date,
435 created_at: Utc::now(),
436 document_type: "SA".to_string(),
437 currency: "USD".to_string(),
438 exchange_rate: Decimal::ONE,
439 reference: None,
440 header_text: None,
441 created_by: "SYSTEM".to_string(),
442 user_persona: "automated_system".to_string(),
443 source: TransactionSource::Automated,
444 business_process: Some(BusinessProcess::R2R),
445 ledger: "0L".to_string(),
446 is_fraud: false,
447 fraud_type: None,
448 is_fraud_propagated: false,
449 fraud_source_document_id: None,
450 is_anomaly: false,
452 anomaly_id: None,
453 anomaly_type: None,
454 batch_id: None,
455 is_manual: false,
457 is_post_close: false,
458 source_system: String::new(),
459 created_date: None,
460 control_ids: Vec::new(),
462 sox_relevant: false,
463 control_status: super::internal_control::ControlStatus::default(),
464 sod_violation: false,
465 sod_conflict_type: None,
466 is_elimination: false,
468 ic_pair_id: None,
470 ic_partner_entity: None,
471 approval_workflow: None,
473 source_document: None,
475 approved_by: None,
476 approval_date: None,
477 ocpm_event_ids: Vec::new(),
479 ocpm_object_ids: Vec::new(),
480 ocpm_case_id: None,
481 }
482 }
483
484 pub fn with_deterministic_id(
489 company_code: String,
490 posting_date: NaiveDate,
491 document_id: Uuid,
492 ) -> Self {
493 Self {
494 document_id,
495 company_code,
496 fiscal_year: posting_date.year() as u16,
497 fiscal_period: posting_date.month() as u8,
498 posting_date,
499 document_date: posting_date,
500 created_at: Utc::now(),
501 document_type: "SA".to_string(),
502 currency: "USD".to_string(),
503 exchange_rate: Decimal::ONE,
504 reference: None,
505 header_text: None,
506 created_by: "SYSTEM".to_string(),
507 user_persona: "automated_system".to_string(),
508 source: TransactionSource::Automated,
509 business_process: Some(BusinessProcess::R2R),
510 ledger: "0L".to_string(),
511 is_fraud: false,
512 fraud_type: None,
513 is_fraud_propagated: false,
514 fraud_source_document_id: None,
515 is_anomaly: false,
517 anomaly_id: None,
518 anomaly_type: None,
519 batch_id: None,
520 is_manual: false,
522 is_post_close: false,
523 source_system: String::new(),
524 created_date: None,
525 control_ids: Vec::new(),
527 sox_relevant: false,
528 control_status: super::internal_control::ControlStatus::default(),
529 sod_violation: false,
530 sod_conflict_type: None,
531 is_elimination: false,
533 ic_pair_id: None,
535 ic_partner_entity: None,
536 approval_workflow: None,
538 source_document: None,
540 approved_by: None,
541 approval_date: None,
542 ocpm_event_ids: Vec::new(),
544 ocpm_object_ids: Vec::new(),
545 ocpm_case_id: None,
546 }
547 }
548
549 pub fn propagate_fraud_from_documents(
559 &mut self,
560 fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
561 ) -> bool {
562 let doc_id = match self
563 .source_document
564 .as_ref()
565 .and_then(DocumentRef::document_id)
566 {
567 Some(id) => id,
568 None => return false,
569 };
570 if let Some(ft) = fraud_map.get(doc_id) {
571 self.is_fraud = true;
572 self.fraud_type = Some(*ft);
573 self.is_fraud_propagated = true;
574 self.fraud_source_document_id = Some(doc_id.to_string());
575 return true;
576 }
577 false
578 }
579}
580
581use chrono::Datelike;
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct JournalEntryLine {
589 pub document_id: Uuid,
591
592 pub line_number: u32,
594
595 pub gl_account: String,
597
598 #[serde(default)]
600 pub account_code: String,
601
602 #[serde(default)]
604 pub account_description: Option<String>,
605
606 #[serde(with = "crate::serde_decimal")]
608 pub debit_amount: Decimal,
609
610 #[serde(with = "crate::serde_decimal")]
612 pub credit_amount: Decimal,
613
614 #[serde(with = "crate::serde_decimal")]
616 pub local_amount: Decimal,
617
618 #[serde(default, with = "crate::serde_decimal::option")]
620 pub group_amount: Option<Decimal>,
621
622 pub cost_center: Option<String>,
624
625 pub profit_center: Option<String>,
627
628 pub segment: Option<String>,
630
631 pub functional_area: Option<String>,
633
634 pub line_text: Option<String>,
636
637 #[serde(default)]
639 pub text: Option<String>,
640
641 #[serde(default)]
643 pub reference: Option<String>,
644
645 #[serde(default)]
647 pub value_date: Option<NaiveDate>,
648
649 pub tax_code: Option<String>,
651
652 #[serde(default, with = "crate::serde_decimal::option")]
654 pub tax_amount: Option<Decimal>,
655
656 pub assignment: Option<String>,
658
659 pub offsetting_account: Option<String>,
661
662 pub is_suspense: bool,
664
665 pub trading_partner: Option<String>,
667
668 #[serde(default, with = "crate::serde_decimal::option")]
670 pub quantity: Option<Decimal>,
671
672 pub unit_of_measure: Option<String>,
674
675 #[serde(default)]
677 pub unit: Option<String>,
678
679 #[serde(default)]
681 pub project_code: Option<String>,
682
683 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub auxiliary_account_number: Option<String>,
687
688 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub auxiliary_account_label: Option<String>,
692
693 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub lettrage: Option<String>,
697
698 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub lettrage_date: Option<NaiveDate>,
702
703 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub transaction_id: Option<String>,
711}
712
713impl JournalEntryLine {
714 #[inline]
716 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
717 Self {
718 document_id,
719 line_number,
720 gl_account: gl_account.clone(),
721 account_code: gl_account,
722 account_description: None,
723 debit_amount: amount,
724 credit_amount: Decimal::ZERO,
725 local_amount: amount,
726 group_amount: None,
727 cost_center: None,
728 profit_center: None,
729 segment: None,
730 functional_area: None,
731 line_text: None,
732 text: None,
733 reference: None,
734 value_date: None,
735 tax_code: None,
736 tax_amount: None,
737 assignment: None,
738 offsetting_account: None,
739 is_suspense: false,
740 trading_partner: None,
741 quantity: None,
742 unit_of_measure: None,
743 unit: None,
744 project_code: None,
745 auxiliary_account_number: None,
746 auxiliary_account_label: None,
747 lettrage: None,
748 lettrage_date: None,
749 transaction_id: None,
750 }
751 }
752
753 #[inline]
755 pub fn credit(
756 document_id: Uuid,
757 line_number: u32,
758 gl_account: String,
759 amount: Decimal,
760 ) -> Self {
761 Self {
762 document_id,
763 line_number,
764 gl_account: gl_account.clone(),
765 account_code: gl_account,
766 account_description: None,
767 debit_amount: Decimal::ZERO,
768 credit_amount: amount,
769 local_amount: -amount,
770 group_amount: None,
771 cost_center: None,
772 profit_center: None,
773 segment: None,
774 functional_area: None,
775 line_text: None,
776 text: None,
777 reference: None,
778 value_date: None,
779 tax_code: None,
780 tax_amount: None,
781 assignment: None,
782 offsetting_account: None,
783 is_suspense: false,
784 trading_partner: None,
785 quantity: None,
786 unit_of_measure: None,
787 unit: None,
788 project_code: None,
789 auxiliary_account_number: None,
790 auxiliary_account_label: None,
791 lettrage: None,
792 lettrage_date: None,
793 transaction_id: None,
794 }
795 }
796
797 #[inline]
799 pub fn is_debit(&self) -> bool {
800 self.debit_amount > Decimal::ZERO
801 }
802
803 #[inline]
805 pub fn is_credit(&self) -> bool {
806 self.credit_amount > Decimal::ZERO
807 }
808
809 #[inline]
811 pub fn signed_amount(&self) -> Decimal {
812 self.debit_amount - self.credit_amount
813 }
814
815 pub fn derive_transaction_id(document_id: Uuid, line_number: u32) -> String {
821 let mut input = Vec::with_capacity(20);
822 input.extend_from_slice(document_id.as_bytes());
823 input.extend_from_slice(&line_number.to_le_bytes());
824 Uuid::new_v5(&Uuid::NAMESPACE_OID, &input).to_string()
825 }
826
827 pub fn ensure_transaction_id(&mut self) {
829 if self.transaction_id.is_none() {
830 self.transaction_id = Some(Self::derive_transaction_id(
831 self.document_id,
832 self.line_number,
833 ));
834 }
835 }
836
837 #[allow(clippy::misnamed_getters)]
841 pub fn account_code(&self) -> &str {
842 &self.gl_account
843 }
844
845 pub fn account_description(&self) -> &str {
847 ""
849 }
850}
851
852impl Default for JournalEntryLine {
853 fn default() -> Self {
854 Self {
855 document_id: Uuid::nil(),
856 line_number: 0,
857 gl_account: String::new(),
858 account_code: String::new(),
859 account_description: None,
860 debit_amount: Decimal::ZERO,
861 credit_amount: Decimal::ZERO,
862 local_amount: Decimal::ZERO,
863 group_amount: None,
864 cost_center: None,
865 profit_center: None,
866 segment: None,
867 functional_area: None,
868 line_text: None,
869 text: None,
870 reference: None,
871 value_date: None,
872 tax_code: None,
873 tax_amount: None,
874 assignment: None,
875 offsetting_account: None,
876 is_suspense: false,
877 trading_partner: None,
878 quantity: None,
879 unit_of_measure: None,
880 unit: None,
881 project_code: None,
882 auxiliary_account_number: None,
883 auxiliary_account_label: None,
884 lettrage: None,
885 lettrage_date: None,
886 transaction_id: None,
887 }
888 }
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize)]
901pub struct JournalEntry {
902 pub header: JournalEntryHeader,
904 pub lines: SmallVec<[JournalEntryLine; 4]>,
907}
908
909impl JournalEntry {
910 pub fn new(header: JournalEntryHeader) -> Self {
912 Self {
913 header,
914 lines: SmallVec::new(),
915 }
916 }
917
918 pub fn new_simple(
924 document_number: String,
925 company_code: String,
926 posting_date: NaiveDate,
927 description: String,
928 ) -> Self {
929 let mut header = JournalEntryHeader::new(company_code, posting_date);
930 header.header_text = Some(description);
931 header.reference = Some(document_number);
932 Self {
933 header,
934 lines: SmallVec::new(),
935 }
936 }
937
938 #[inline]
942 pub fn add_line(&mut self, mut line: JournalEntryLine) {
943 line.document_id = self.header.document_id;
944 self.lines.push(line);
945 }
946
947 pub fn total_debit(&self) -> Decimal {
949 self.lines.iter().map(|l| l.debit_amount).sum()
950 }
951
952 pub fn total_credit(&self) -> Decimal {
954 self.lines.iter().map(|l| l.credit_amount).sum()
955 }
956
957 pub fn is_balanced(&self) -> bool {
959 self.total_debit() == self.total_credit()
960 }
961
962 pub fn balance_difference(&self) -> Decimal {
964 self.total_debit() - self.total_credit()
965 }
966
967 pub fn line_count(&self) -> usize {
969 self.lines.len()
970 }
971
972 pub fn has_even_line_count(&self) -> bool {
974 self.lines.len().is_multiple_of(2)
975 }
976
977 pub fn debit_credit_counts(&self) -> (usize, usize) {
979 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
980 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
981 (debits, credits)
982 }
983
984 pub fn has_equal_debit_credit_counts(&self) -> bool {
986 let (d, c) = self.debit_credit_counts();
987 d == c
988 }
989
990 pub fn unique_accounts(&self) -> Vec<&str> {
992 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
993 accounts.sort();
994 accounts.dedup();
995 accounts
996 }
997
998 pub fn has_suspense_posting(&self) -> bool {
1000 self.lines.iter().any(|l| l.is_suspense)
1001 }
1002
1003 pub fn company_code(&self) -> &str {
1007 &self.header.company_code
1008 }
1009
1010 pub fn document_number(&self) -> String {
1012 self.header.document_id.to_string()
1013 }
1014
1015 pub fn posting_date(&self) -> NaiveDate {
1017 self.header.posting_date
1018 }
1019
1020 pub fn document_date(&self) -> NaiveDate {
1022 self.header.document_date
1023 }
1024
1025 pub fn fiscal_year(&self) -> u16 {
1027 self.header.fiscal_year
1028 }
1029
1030 pub fn fiscal_period(&self) -> u8 {
1032 self.header.fiscal_period
1033 }
1034
1035 pub fn currency(&self) -> &str {
1037 &self.header.currency
1038 }
1039
1040 pub fn is_fraud(&self) -> bool {
1042 self.header.is_fraud
1043 }
1044
1045 pub fn has_sod_violation(&self) -> bool {
1047 self.header.sod_violation
1048 }
1049
1050 pub fn description(&self) -> Option<&str> {
1052 self.header.header_text.as_deref()
1053 }
1054
1055 pub fn set_description(&mut self, description: String) {
1057 self.header.header_text = Some(description);
1058 }
1059}
1060
1061#[cfg(test)]
1062#[allow(clippy::unwrap_used)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn test_balanced_entry() {
1068 let header = JournalEntryHeader::new(
1069 "1000".to_string(),
1070 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1071 );
1072 let mut entry = JournalEntry::new(header);
1073
1074 entry.add_line(JournalEntryLine::debit(
1075 entry.header.document_id,
1076 1,
1077 "100000".to_string(),
1078 Decimal::from(1000),
1079 ));
1080 entry.add_line(JournalEntryLine::credit(
1081 entry.header.document_id,
1082 2,
1083 "200000".to_string(),
1084 Decimal::from(1000),
1085 ));
1086
1087 assert!(entry.is_balanced());
1088 assert_eq!(entry.line_count(), 2);
1089 assert!(entry.has_even_line_count());
1090 assert!(entry.has_equal_debit_credit_counts());
1091 }
1092
1093 #[test]
1094 fn test_unbalanced_entry() {
1095 let header = JournalEntryHeader::new(
1096 "1000".to_string(),
1097 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1098 );
1099 let mut entry = JournalEntry::new(header);
1100
1101 entry.add_line(JournalEntryLine::debit(
1102 entry.header.document_id,
1103 1,
1104 "100000".to_string(),
1105 Decimal::from(1000),
1106 ));
1107 entry.add_line(JournalEntryLine::credit(
1108 entry.header.document_id,
1109 2,
1110 "200000".to_string(),
1111 Decimal::from(500),
1112 ));
1113
1114 assert!(!entry.is_balanced());
1115 assert_eq!(entry.balance_difference(), Decimal::from(500));
1116 }
1117}