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
704impl JournalEntryLine {
705 #[inline]
707 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
708 Self {
709 document_id,
710 line_number,
711 gl_account: gl_account.clone(),
712 account_code: gl_account,
713 account_description: None,
714 debit_amount: amount,
715 credit_amount: Decimal::ZERO,
716 local_amount: amount,
717 group_amount: None,
718 cost_center: None,
719 profit_center: None,
720 segment: None,
721 functional_area: None,
722 line_text: None,
723 text: None,
724 reference: None,
725 value_date: None,
726 tax_code: None,
727 tax_amount: None,
728 assignment: None,
729 offsetting_account: None,
730 is_suspense: false,
731 trading_partner: None,
732 quantity: None,
733 unit_of_measure: None,
734 unit: None,
735 project_code: None,
736 auxiliary_account_number: None,
737 auxiliary_account_label: None,
738 lettrage: None,
739 lettrage_date: None,
740 }
741 }
742
743 #[inline]
745 pub fn credit(
746 document_id: Uuid,
747 line_number: u32,
748 gl_account: String,
749 amount: Decimal,
750 ) -> Self {
751 Self {
752 document_id,
753 line_number,
754 gl_account: gl_account.clone(),
755 account_code: gl_account,
756 account_description: None,
757 debit_amount: Decimal::ZERO,
758 credit_amount: amount,
759 local_amount: -amount,
760 group_amount: None,
761 cost_center: None,
762 profit_center: None,
763 segment: None,
764 functional_area: None,
765 line_text: None,
766 text: None,
767 reference: None,
768 value_date: None,
769 tax_code: None,
770 tax_amount: None,
771 assignment: None,
772 offsetting_account: None,
773 is_suspense: false,
774 trading_partner: None,
775 quantity: None,
776 unit_of_measure: None,
777 unit: None,
778 project_code: None,
779 auxiliary_account_number: None,
780 auxiliary_account_label: None,
781 lettrage: None,
782 lettrage_date: None,
783 }
784 }
785
786 #[inline]
788 pub fn is_debit(&self) -> bool {
789 self.debit_amount > Decimal::ZERO
790 }
791
792 #[inline]
794 pub fn is_credit(&self) -> bool {
795 self.credit_amount > Decimal::ZERO
796 }
797
798 #[inline]
800 pub fn signed_amount(&self) -> Decimal {
801 self.debit_amount - self.credit_amount
802 }
803
804 #[allow(clippy::misnamed_getters)]
808 pub fn account_code(&self) -> &str {
809 &self.gl_account
810 }
811
812 pub fn account_description(&self) -> &str {
814 ""
816 }
817}
818
819impl Default for JournalEntryLine {
820 fn default() -> Self {
821 Self {
822 document_id: Uuid::nil(),
823 line_number: 0,
824 gl_account: String::new(),
825 account_code: String::new(),
826 account_description: None,
827 debit_amount: Decimal::ZERO,
828 credit_amount: Decimal::ZERO,
829 local_amount: Decimal::ZERO,
830 group_amount: None,
831 cost_center: None,
832 profit_center: None,
833 segment: None,
834 functional_area: None,
835 line_text: None,
836 text: None,
837 reference: None,
838 value_date: None,
839 tax_code: None,
840 tax_amount: None,
841 assignment: None,
842 offsetting_account: None,
843 is_suspense: false,
844 trading_partner: None,
845 quantity: None,
846 unit_of_measure: None,
847 unit: None,
848 project_code: None,
849 auxiliary_account_number: None,
850 auxiliary_account_label: None,
851 lettrage: None,
852 lettrage_date: None,
853 }
854 }
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct JournalEntry {
868 pub header: JournalEntryHeader,
870 pub lines: SmallVec<[JournalEntryLine; 4]>,
873}
874
875impl JournalEntry {
876 pub fn new(header: JournalEntryHeader) -> Self {
878 Self {
879 header,
880 lines: SmallVec::new(),
881 }
882 }
883
884 pub fn new_simple(
890 document_number: String,
891 company_code: String,
892 posting_date: NaiveDate,
893 description: String,
894 ) -> Self {
895 let mut header = JournalEntryHeader::new(company_code, posting_date);
896 header.header_text = Some(description);
897 header.reference = Some(document_number);
898 Self {
899 header,
900 lines: SmallVec::new(),
901 }
902 }
903
904 #[inline]
908 pub fn add_line(&mut self, mut line: JournalEntryLine) {
909 line.document_id = self.header.document_id;
910 self.lines.push(line);
911 }
912
913 pub fn total_debit(&self) -> Decimal {
915 self.lines.iter().map(|l| l.debit_amount).sum()
916 }
917
918 pub fn total_credit(&self) -> Decimal {
920 self.lines.iter().map(|l| l.credit_amount).sum()
921 }
922
923 pub fn is_balanced(&self) -> bool {
925 self.total_debit() == self.total_credit()
926 }
927
928 pub fn balance_difference(&self) -> Decimal {
930 self.total_debit() - self.total_credit()
931 }
932
933 pub fn line_count(&self) -> usize {
935 self.lines.len()
936 }
937
938 pub fn has_even_line_count(&self) -> bool {
940 self.lines.len().is_multiple_of(2)
941 }
942
943 pub fn debit_credit_counts(&self) -> (usize, usize) {
945 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
946 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
947 (debits, credits)
948 }
949
950 pub fn has_equal_debit_credit_counts(&self) -> bool {
952 let (d, c) = self.debit_credit_counts();
953 d == c
954 }
955
956 pub fn unique_accounts(&self) -> Vec<&str> {
958 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
959 accounts.sort();
960 accounts.dedup();
961 accounts
962 }
963
964 pub fn has_suspense_posting(&self) -> bool {
966 self.lines.iter().any(|l| l.is_suspense)
967 }
968
969 pub fn company_code(&self) -> &str {
973 &self.header.company_code
974 }
975
976 pub fn document_number(&self) -> String {
978 self.header.document_id.to_string()
979 }
980
981 pub fn posting_date(&self) -> NaiveDate {
983 self.header.posting_date
984 }
985
986 pub fn document_date(&self) -> NaiveDate {
988 self.header.document_date
989 }
990
991 pub fn fiscal_year(&self) -> u16 {
993 self.header.fiscal_year
994 }
995
996 pub fn fiscal_period(&self) -> u8 {
998 self.header.fiscal_period
999 }
1000
1001 pub fn currency(&self) -> &str {
1003 &self.header.currency
1004 }
1005
1006 pub fn is_fraud(&self) -> bool {
1008 self.header.is_fraud
1009 }
1010
1011 pub fn has_sod_violation(&self) -> bool {
1013 self.header.sod_violation
1014 }
1015
1016 pub fn description(&self) -> Option<&str> {
1018 self.header.header_text.as_deref()
1019 }
1020
1021 pub fn set_description(&mut self, description: String) {
1023 self.header.header_text = Some(description);
1024 }
1025}
1026
1027#[cfg(test)]
1028#[allow(clippy::unwrap_used)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn test_balanced_entry() {
1034 let header = JournalEntryHeader::new(
1035 "1000".to_string(),
1036 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1037 );
1038 let mut entry = JournalEntry::new(header);
1039
1040 entry.add_line(JournalEntryLine::debit(
1041 entry.header.document_id,
1042 1,
1043 "100000".to_string(),
1044 Decimal::from(1000),
1045 ));
1046 entry.add_line(JournalEntryLine::credit(
1047 entry.header.document_id,
1048 2,
1049 "200000".to_string(),
1050 Decimal::from(1000),
1051 ));
1052
1053 assert!(entry.is_balanced());
1054 assert_eq!(entry.line_count(), 2);
1055 assert!(entry.has_even_line_count());
1056 assert!(entry.has_equal_debit_credit_counts());
1057 }
1058
1059 #[test]
1060 fn test_unbalanced_entry() {
1061 let header = JournalEntryHeader::new(
1062 "1000".to_string(),
1063 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1064 );
1065 let mut entry = JournalEntry::new(header);
1066
1067 entry.add_line(JournalEntryLine::debit(
1068 entry.header.document_id,
1069 1,
1070 "100000".to_string(),
1071 Decimal::from(1000),
1072 ));
1073 entry.add_line(JournalEntryLine::credit(
1074 entry.header.document_id,
1075 2,
1076 "200000".to_string(),
1077 Decimal::from(500),
1078 ));
1079
1080 assert!(!entry.is_balanced());
1081 assert_eq!(entry.balance_difference(), Decimal::from(500));
1082 }
1083}