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)]
386 pub approval_workflow: Option<ApprovalWorkflow>,
387
388 #[serde(default)]
391 pub source_document: Option<DocumentRef>,
392 #[serde(default)]
394 pub approved_by: Option<String>,
395 #[serde(default)]
397 pub approval_date: Option<NaiveDate>,
398
399 #[serde(default)]
402 pub ocpm_event_ids: Vec<Uuid>,
403
404 #[serde(default)]
406 pub ocpm_object_ids: Vec<Uuid>,
407
408 #[serde(default)]
410 pub ocpm_case_id: Option<Uuid>,
411}
412
413impl JournalEntryHeader {
414 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
416 Self {
417 document_id: Uuid::now_v7(),
418 company_code,
419 fiscal_year: posting_date.year() as u16,
420 fiscal_period: posting_date.month() as u8,
421 posting_date,
422 document_date: posting_date,
423 created_at: Utc::now(),
424 document_type: "SA".to_string(),
425 currency: "USD".to_string(),
426 exchange_rate: Decimal::ONE,
427 reference: None,
428 header_text: None,
429 created_by: "SYSTEM".to_string(),
430 user_persona: "automated_system".to_string(),
431 source: TransactionSource::Automated,
432 business_process: Some(BusinessProcess::R2R),
433 ledger: "0L".to_string(),
434 is_fraud: false,
435 fraud_type: None,
436 is_fraud_propagated: false,
437 fraud_source_document_id: None,
438 is_anomaly: false,
440 anomaly_id: None,
441 anomaly_type: None,
442 batch_id: None,
443 is_manual: false,
445 is_post_close: false,
446 source_system: String::new(),
447 created_date: None,
448 control_ids: Vec::new(),
450 sox_relevant: false,
451 control_status: super::internal_control::ControlStatus::default(),
452 sod_violation: false,
453 sod_conflict_type: None,
454 is_elimination: false,
456 approval_workflow: None,
458 source_document: None,
460 approved_by: None,
461 approval_date: None,
462 ocpm_event_ids: Vec::new(),
464 ocpm_object_ids: Vec::new(),
465 ocpm_case_id: None,
466 }
467 }
468
469 pub fn with_deterministic_id(
474 company_code: String,
475 posting_date: NaiveDate,
476 document_id: Uuid,
477 ) -> Self {
478 Self {
479 document_id,
480 company_code,
481 fiscal_year: posting_date.year() as u16,
482 fiscal_period: posting_date.month() as u8,
483 posting_date,
484 document_date: posting_date,
485 created_at: Utc::now(),
486 document_type: "SA".to_string(),
487 currency: "USD".to_string(),
488 exchange_rate: Decimal::ONE,
489 reference: None,
490 header_text: None,
491 created_by: "SYSTEM".to_string(),
492 user_persona: "automated_system".to_string(),
493 source: TransactionSource::Automated,
494 business_process: Some(BusinessProcess::R2R),
495 ledger: "0L".to_string(),
496 is_fraud: false,
497 fraud_type: None,
498 is_fraud_propagated: false,
499 fraud_source_document_id: None,
500 is_anomaly: false,
502 anomaly_id: None,
503 anomaly_type: None,
504 batch_id: None,
505 is_manual: false,
507 is_post_close: false,
508 source_system: String::new(),
509 created_date: None,
510 control_ids: Vec::new(),
512 sox_relevant: false,
513 control_status: super::internal_control::ControlStatus::default(),
514 sod_violation: false,
515 sod_conflict_type: None,
516 is_elimination: false,
518 approval_workflow: None,
520 source_document: None,
522 approved_by: None,
523 approval_date: None,
524 ocpm_event_ids: Vec::new(),
526 ocpm_object_ids: Vec::new(),
527 ocpm_case_id: None,
528 }
529 }
530
531 pub fn propagate_fraud_from_documents(
541 &mut self,
542 fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
543 ) -> bool {
544 let doc_id = match self
545 .source_document
546 .as_ref()
547 .and_then(DocumentRef::document_id)
548 {
549 Some(id) => id,
550 None => return false,
551 };
552 if let Some(ft) = fraud_map.get(doc_id) {
553 self.is_fraud = true;
554 self.fraud_type = Some(*ft);
555 self.is_fraud_propagated = true;
556 self.fraud_source_document_id = Some(doc_id.to_string());
557 return true;
558 }
559 false
560 }
561}
562
563use chrono::Datelike;
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct JournalEntryLine {
571 pub document_id: Uuid,
573
574 pub line_number: u32,
576
577 pub gl_account: String,
579
580 #[serde(default)]
582 pub account_code: String,
583
584 #[serde(default)]
586 pub account_description: Option<String>,
587
588 #[serde(with = "crate::serde_decimal")]
590 pub debit_amount: Decimal,
591
592 #[serde(with = "crate::serde_decimal")]
594 pub credit_amount: Decimal,
595
596 #[serde(with = "crate::serde_decimal")]
598 pub local_amount: Decimal,
599
600 #[serde(default, with = "crate::serde_decimal::option")]
602 pub group_amount: Option<Decimal>,
603
604 pub cost_center: Option<String>,
606
607 pub profit_center: Option<String>,
609
610 pub segment: Option<String>,
612
613 pub functional_area: Option<String>,
615
616 pub line_text: Option<String>,
618
619 #[serde(default)]
621 pub text: Option<String>,
622
623 #[serde(default)]
625 pub reference: Option<String>,
626
627 #[serde(default)]
629 pub value_date: Option<NaiveDate>,
630
631 pub tax_code: Option<String>,
633
634 #[serde(default, with = "crate::serde_decimal::option")]
636 pub tax_amount: Option<Decimal>,
637
638 pub assignment: Option<String>,
640
641 pub offsetting_account: Option<String>,
643
644 pub is_suspense: bool,
646
647 pub trading_partner: Option<String>,
649
650 #[serde(default, with = "crate::serde_decimal::option")]
652 pub quantity: Option<Decimal>,
653
654 pub unit_of_measure: Option<String>,
656
657 #[serde(default)]
659 pub unit: Option<String>,
660
661 #[serde(default)]
663 pub project_code: Option<String>,
664
665 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub auxiliary_account_number: Option<String>,
669
670 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub auxiliary_account_label: Option<String>,
674
675 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub lettrage: Option<String>,
679
680 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub lettrage_date: Option<NaiveDate>,
684}
685
686impl JournalEntryLine {
687 #[inline]
689 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
690 Self {
691 document_id,
692 line_number,
693 gl_account: gl_account.clone(),
694 account_code: gl_account,
695 account_description: None,
696 debit_amount: amount,
697 credit_amount: Decimal::ZERO,
698 local_amount: amount,
699 group_amount: None,
700 cost_center: None,
701 profit_center: None,
702 segment: None,
703 functional_area: None,
704 line_text: None,
705 text: None,
706 reference: None,
707 value_date: None,
708 tax_code: None,
709 tax_amount: None,
710 assignment: None,
711 offsetting_account: None,
712 is_suspense: false,
713 trading_partner: None,
714 quantity: None,
715 unit_of_measure: None,
716 unit: None,
717 project_code: None,
718 auxiliary_account_number: None,
719 auxiliary_account_label: None,
720 lettrage: None,
721 lettrage_date: None,
722 }
723 }
724
725 #[inline]
727 pub fn credit(
728 document_id: Uuid,
729 line_number: u32,
730 gl_account: String,
731 amount: Decimal,
732 ) -> Self {
733 Self {
734 document_id,
735 line_number,
736 gl_account: gl_account.clone(),
737 account_code: gl_account,
738 account_description: None,
739 debit_amount: Decimal::ZERO,
740 credit_amount: amount,
741 local_amount: -amount,
742 group_amount: None,
743 cost_center: None,
744 profit_center: None,
745 segment: None,
746 functional_area: None,
747 line_text: None,
748 text: None,
749 reference: None,
750 value_date: None,
751 tax_code: None,
752 tax_amount: None,
753 assignment: None,
754 offsetting_account: None,
755 is_suspense: false,
756 trading_partner: None,
757 quantity: None,
758 unit_of_measure: None,
759 unit: None,
760 project_code: None,
761 auxiliary_account_number: None,
762 auxiliary_account_label: None,
763 lettrage: None,
764 lettrage_date: None,
765 }
766 }
767
768 #[inline]
770 pub fn is_debit(&self) -> bool {
771 self.debit_amount > Decimal::ZERO
772 }
773
774 #[inline]
776 pub fn is_credit(&self) -> bool {
777 self.credit_amount > Decimal::ZERO
778 }
779
780 #[inline]
782 pub fn signed_amount(&self) -> Decimal {
783 self.debit_amount - self.credit_amount
784 }
785
786 #[allow(clippy::misnamed_getters)]
790 pub fn account_code(&self) -> &str {
791 &self.gl_account
792 }
793
794 pub fn account_description(&self) -> &str {
796 ""
798 }
799}
800
801impl Default for JournalEntryLine {
802 fn default() -> Self {
803 Self {
804 document_id: Uuid::nil(),
805 line_number: 0,
806 gl_account: String::new(),
807 account_code: String::new(),
808 account_description: None,
809 debit_amount: Decimal::ZERO,
810 credit_amount: Decimal::ZERO,
811 local_amount: Decimal::ZERO,
812 group_amount: None,
813 cost_center: None,
814 profit_center: None,
815 segment: None,
816 functional_area: None,
817 line_text: None,
818 text: None,
819 reference: None,
820 value_date: None,
821 tax_code: None,
822 tax_amount: None,
823 assignment: None,
824 offsetting_account: None,
825 is_suspense: false,
826 trading_partner: None,
827 quantity: None,
828 unit_of_measure: None,
829 unit: None,
830 project_code: None,
831 auxiliary_account_number: None,
832 auxiliary_account_label: None,
833 lettrage: None,
834 lettrage_date: None,
835 }
836 }
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct JournalEntry {
850 pub header: JournalEntryHeader,
852 pub lines: SmallVec<[JournalEntryLine; 4]>,
855}
856
857impl JournalEntry {
858 pub fn new(header: JournalEntryHeader) -> Self {
860 Self {
861 header,
862 lines: SmallVec::new(),
863 }
864 }
865
866 pub fn new_simple(
872 document_number: String,
873 company_code: String,
874 posting_date: NaiveDate,
875 description: String,
876 ) -> Self {
877 let mut header = JournalEntryHeader::new(company_code, posting_date);
878 header.header_text = Some(description);
879 header.reference = Some(document_number);
880 Self {
881 header,
882 lines: SmallVec::new(),
883 }
884 }
885
886 #[inline]
890 pub fn add_line(&mut self, mut line: JournalEntryLine) {
891 line.document_id = self.header.document_id;
892 self.lines.push(line);
893 }
894
895 pub fn total_debit(&self) -> Decimal {
897 self.lines.iter().map(|l| l.debit_amount).sum()
898 }
899
900 pub fn total_credit(&self) -> Decimal {
902 self.lines.iter().map(|l| l.credit_amount).sum()
903 }
904
905 pub fn is_balanced(&self) -> bool {
907 self.total_debit() == self.total_credit()
908 }
909
910 pub fn balance_difference(&self) -> Decimal {
912 self.total_debit() - self.total_credit()
913 }
914
915 pub fn line_count(&self) -> usize {
917 self.lines.len()
918 }
919
920 pub fn has_even_line_count(&self) -> bool {
922 self.lines.len().is_multiple_of(2)
923 }
924
925 pub fn debit_credit_counts(&self) -> (usize, usize) {
927 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
928 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
929 (debits, credits)
930 }
931
932 pub fn has_equal_debit_credit_counts(&self) -> bool {
934 let (d, c) = self.debit_credit_counts();
935 d == c
936 }
937
938 pub fn unique_accounts(&self) -> Vec<&str> {
940 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
941 accounts.sort();
942 accounts.dedup();
943 accounts
944 }
945
946 pub fn has_suspense_posting(&self) -> bool {
948 self.lines.iter().any(|l| l.is_suspense)
949 }
950
951 pub fn company_code(&self) -> &str {
955 &self.header.company_code
956 }
957
958 pub fn document_number(&self) -> String {
960 self.header.document_id.to_string()
961 }
962
963 pub fn posting_date(&self) -> NaiveDate {
965 self.header.posting_date
966 }
967
968 pub fn document_date(&self) -> NaiveDate {
970 self.header.document_date
971 }
972
973 pub fn fiscal_year(&self) -> u16 {
975 self.header.fiscal_year
976 }
977
978 pub fn fiscal_period(&self) -> u8 {
980 self.header.fiscal_period
981 }
982
983 pub fn currency(&self) -> &str {
985 &self.header.currency
986 }
987
988 pub fn is_fraud(&self) -> bool {
990 self.header.is_fraud
991 }
992
993 pub fn has_sod_violation(&self) -> bool {
995 self.header.sod_violation
996 }
997
998 pub fn description(&self) -> Option<&str> {
1000 self.header.header_text.as_deref()
1001 }
1002
1003 pub fn set_description(&mut self, description: String) {
1005 self.header.header_text = Some(description);
1006 }
1007}
1008
1009#[cfg(test)]
1010#[allow(clippy::unwrap_used)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1015 fn test_balanced_entry() {
1016 let header = JournalEntryHeader::new(
1017 "1000".to_string(),
1018 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1019 );
1020 let mut entry = JournalEntry::new(header);
1021
1022 entry.add_line(JournalEntryLine::debit(
1023 entry.header.document_id,
1024 1,
1025 "100000".to_string(),
1026 Decimal::from(1000),
1027 ));
1028 entry.add_line(JournalEntryLine::credit(
1029 entry.header.document_id,
1030 2,
1031 "200000".to_string(),
1032 Decimal::from(1000),
1033 ));
1034
1035 assert!(entry.is_balanced());
1036 assert_eq!(entry.line_count(), 2);
1037 assert!(entry.has_even_line_count());
1038 assert!(entry.has_equal_debit_credit_counts());
1039 }
1040
1041 #[test]
1042 fn test_unbalanced_entry() {
1043 let header = JournalEntryHeader::new(
1044 "1000".to_string(),
1045 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1046 );
1047 let mut entry = JournalEntry::new(header);
1048
1049 entry.add_line(JournalEntryLine::debit(
1050 entry.header.document_id,
1051 1,
1052 "100000".to_string(),
1053 Decimal::from(1000),
1054 ));
1055 entry.add_line(JournalEntryLine::credit(
1056 entry.header.document_id,
1057 2,
1058 "200000".to_string(),
1059 Decimal::from(500),
1060 ));
1061
1062 assert!(!entry.is_balanced());
1063 assert_eq!(entry.balance_difference(), Decimal::from(500));
1064 }
1065}