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 pub created_at: DateTime<Utc>,
255
256 pub document_type: String,
258
259 pub currency: String,
261
262 #[serde(with = "rust_decimal::serde::str")]
264 pub exchange_rate: Decimal,
265
266 pub reference: Option<String>,
268
269 pub header_text: Option<String>,
271
272 pub created_by: String,
274
275 pub user_persona: String,
277
278 pub source: TransactionSource,
280
281 pub business_process: Option<BusinessProcess>,
283
284 pub ledger: String,
286
287 pub is_fraud: bool,
289
290 pub fraud_type: Option<FraudType>,
292
293 #[serde(default)]
296 pub is_anomaly: bool,
297
298 #[serde(default)]
300 pub anomaly_id: Option<String>,
301
302 #[serde(default)]
304 pub anomaly_type: Option<String>,
305
306 pub batch_id: Option<Uuid>,
308
309 #[serde(default)]
313 pub is_manual: bool,
314
315 #[serde(default)]
318 pub is_post_close: bool,
319
320 #[serde(default)]
323 pub source_system: String,
324
325 #[serde(default)]
329 pub created_date: Option<NaiveDateTime>,
330
331 #[serde(default)]
334 pub control_ids: Vec<String>,
335
336 #[serde(default)]
338 pub sox_relevant: bool,
339
340 #[serde(default)]
342 pub control_status: super::internal_control::ControlStatus,
343
344 #[serde(default)]
346 pub sod_violation: bool,
347
348 #[serde(default)]
350 pub sod_conflict_type: Option<super::sod::SodConflictType>,
351
352 #[serde(default)]
354 pub is_elimination: bool,
355
356 #[serde(default)]
359 pub approval_workflow: Option<ApprovalWorkflow>,
360
361 #[serde(default)]
364 pub source_document: Option<DocumentRef>,
365 #[serde(default)]
367 pub approved_by: Option<String>,
368 #[serde(default)]
370 pub approval_date: Option<NaiveDate>,
371
372 #[serde(default)]
375 pub ocpm_event_ids: Vec<Uuid>,
376
377 #[serde(default)]
379 pub ocpm_object_ids: Vec<Uuid>,
380
381 #[serde(default)]
383 pub ocpm_case_id: Option<Uuid>,
384}
385
386impl JournalEntryHeader {
387 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
389 Self {
390 document_id: Uuid::now_v7(),
391 company_code,
392 fiscal_year: posting_date.year() as u16,
393 fiscal_period: posting_date.month() as u8,
394 posting_date,
395 document_date: posting_date,
396 created_at: Utc::now(),
397 document_type: "SA".to_string(),
398 currency: "USD".to_string(),
399 exchange_rate: Decimal::ONE,
400 reference: None,
401 header_text: None,
402 created_by: "SYSTEM".to_string(),
403 user_persona: "automated_system".to_string(),
404 source: TransactionSource::Automated,
405 business_process: Some(BusinessProcess::R2R),
406 ledger: "0L".to_string(),
407 is_fraud: false,
408 fraud_type: None,
409 is_anomaly: false,
411 anomaly_id: None,
412 anomaly_type: None,
413 batch_id: None,
414 is_manual: false,
416 is_post_close: false,
417 source_system: String::new(),
418 created_date: None,
419 control_ids: Vec::new(),
421 sox_relevant: false,
422 control_status: super::internal_control::ControlStatus::default(),
423 sod_violation: false,
424 sod_conflict_type: None,
425 is_elimination: false,
427 approval_workflow: None,
429 source_document: None,
431 approved_by: None,
432 approval_date: None,
433 ocpm_event_ids: Vec::new(),
435 ocpm_object_ids: Vec::new(),
436 ocpm_case_id: None,
437 }
438 }
439
440 pub fn with_deterministic_id(
445 company_code: String,
446 posting_date: NaiveDate,
447 document_id: Uuid,
448 ) -> Self {
449 Self {
450 document_id,
451 company_code,
452 fiscal_year: posting_date.year() as u16,
453 fiscal_period: posting_date.month() as u8,
454 posting_date,
455 document_date: posting_date,
456 created_at: Utc::now(),
457 document_type: "SA".to_string(),
458 currency: "USD".to_string(),
459 exchange_rate: Decimal::ONE,
460 reference: None,
461 header_text: None,
462 created_by: "SYSTEM".to_string(),
463 user_persona: "automated_system".to_string(),
464 source: TransactionSource::Automated,
465 business_process: Some(BusinessProcess::R2R),
466 ledger: "0L".to_string(),
467 is_fraud: false,
468 fraud_type: None,
469 is_anomaly: false,
471 anomaly_id: None,
472 anomaly_type: None,
473 batch_id: None,
474 is_manual: false,
476 is_post_close: false,
477 source_system: String::new(),
478 created_date: None,
479 control_ids: Vec::new(),
481 sox_relevant: false,
482 control_status: super::internal_control::ControlStatus::default(),
483 sod_violation: false,
484 sod_conflict_type: None,
485 is_elimination: false,
487 approval_workflow: None,
489 source_document: None,
491 approved_by: None,
492 approval_date: None,
493 ocpm_event_ids: Vec::new(),
495 ocpm_object_ids: Vec::new(),
496 ocpm_case_id: None,
497 }
498 }
499}
500
501use chrono::Datelike;
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct JournalEntryLine {
509 pub document_id: Uuid,
511
512 pub line_number: u32,
514
515 pub gl_account: String,
517
518 #[serde(default)]
520 pub account_code: String,
521
522 #[serde(default)]
524 pub account_description: Option<String>,
525
526 #[serde(with = "rust_decimal::serde::str")]
528 pub debit_amount: Decimal,
529
530 #[serde(with = "rust_decimal::serde::str")]
532 pub credit_amount: Decimal,
533
534 #[serde(with = "rust_decimal::serde::str")]
536 pub local_amount: Decimal,
537
538 #[serde(default, with = "rust_decimal::serde::str_option")]
540 pub group_amount: Option<Decimal>,
541
542 pub cost_center: Option<String>,
544
545 pub profit_center: Option<String>,
547
548 pub segment: Option<String>,
550
551 pub functional_area: Option<String>,
553
554 pub line_text: Option<String>,
556
557 #[serde(default)]
559 pub text: Option<String>,
560
561 #[serde(default)]
563 pub reference: Option<String>,
564
565 #[serde(default)]
567 pub value_date: Option<NaiveDate>,
568
569 pub tax_code: Option<String>,
571
572 #[serde(default, with = "rust_decimal::serde::str_option")]
574 pub tax_amount: Option<Decimal>,
575
576 pub assignment: Option<String>,
578
579 pub offsetting_account: Option<String>,
581
582 pub is_suspense: bool,
584
585 pub trading_partner: Option<String>,
587
588 #[serde(default, with = "rust_decimal::serde::str_option")]
590 pub quantity: Option<Decimal>,
591
592 pub unit_of_measure: Option<String>,
594
595 #[serde(default)]
597 pub unit: Option<String>,
598
599 #[serde(default)]
601 pub project_code: Option<String>,
602
603 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub auxiliary_account_number: Option<String>,
607
608 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub auxiliary_account_label: Option<String>,
612
613 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub lettrage: Option<String>,
617
618 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub lettrage_date: Option<NaiveDate>,
622}
623
624impl JournalEntryLine {
625 #[inline]
627 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
628 Self {
629 document_id,
630 line_number,
631 gl_account: gl_account.clone(),
632 account_code: gl_account,
633 account_description: None,
634 debit_amount: amount,
635 credit_amount: Decimal::ZERO,
636 local_amount: amount,
637 group_amount: None,
638 cost_center: None,
639 profit_center: None,
640 segment: None,
641 functional_area: None,
642 line_text: None,
643 text: None,
644 reference: None,
645 value_date: None,
646 tax_code: None,
647 tax_amount: None,
648 assignment: None,
649 offsetting_account: None,
650 is_suspense: false,
651 trading_partner: None,
652 quantity: None,
653 unit_of_measure: None,
654 unit: None,
655 project_code: None,
656 auxiliary_account_number: None,
657 auxiliary_account_label: None,
658 lettrage: None,
659 lettrage_date: None,
660 }
661 }
662
663 #[inline]
665 pub fn credit(
666 document_id: Uuid,
667 line_number: u32,
668 gl_account: String,
669 amount: Decimal,
670 ) -> Self {
671 Self {
672 document_id,
673 line_number,
674 gl_account: gl_account.clone(),
675 account_code: gl_account,
676 account_description: None,
677 debit_amount: Decimal::ZERO,
678 credit_amount: amount,
679 local_amount: -amount,
680 group_amount: None,
681 cost_center: None,
682 profit_center: None,
683 segment: None,
684 functional_area: None,
685 line_text: None,
686 text: None,
687 reference: None,
688 value_date: None,
689 tax_code: None,
690 tax_amount: None,
691 assignment: None,
692 offsetting_account: None,
693 is_suspense: false,
694 trading_partner: None,
695 quantity: None,
696 unit_of_measure: None,
697 unit: None,
698 project_code: None,
699 auxiliary_account_number: None,
700 auxiliary_account_label: None,
701 lettrage: None,
702 lettrage_date: None,
703 }
704 }
705
706 #[inline]
708 pub fn is_debit(&self) -> bool {
709 self.debit_amount > Decimal::ZERO
710 }
711
712 #[inline]
714 pub fn is_credit(&self) -> bool {
715 self.credit_amount > Decimal::ZERO
716 }
717
718 #[inline]
720 pub fn signed_amount(&self) -> Decimal {
721 self.debit_amount - self.credit_amount
722 }
723
724 #[allow(clippy::misnamed_getters)]
728 pub fn account_code(&self) -> &str {
729 &self.gl_account
730 }
731
732 pub fn account_description(&self) -> &str {
734 ""
736 }
737}
738
739impl Default for JournalEntryLine {
740 fn default() -> Self {
741 Self {
742 document_id: Uuid::nil(),
743 line_number: 0,
744 gl_account: String::new(),
745 account_code: String::new(),
746 account_description: None,
747 debit_amount: Decimal::ZERO,
748 credit_amount: Decimal::ZERO,
749 local_amount: Decimal::ZERO,
750 group_amount: None,
751 cost_center: None,
752 profit_center: None,
753 segment: None,
754 functional_area: None,
755 line_text: None,
756 text: None,
757 reference: None,
758 value_date: None,
759 tax_code: None,
760 tax_amount: None,
761 assignment: None,
762 offsetting_account: None,
763 is_suspense: false,
764 trading_partner: None,
765 quantity: None,
766 unit_of_measure: None,
767 unit: None,
768 project_code: None,
769 auxiliary_account_number: None,
770 auxiliary_account_label: None,
771 lettrage: None,
772 lettrage_date: None,
773 }
774 }
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
787pub struct JournalEntry {
788 pub header: JournalEntryHeader,
790 pub lines: SmallVec<[JournalEntryLine; 4]>,
793}
794
795impl JournalEntry {
796 pub fn new(header: JournalEntryHeader) -> Self {
798 Self {
799 header,
800 lines: SmallVec::new(),
801 }
802 }
803
804 pub fn new_simple(
810 _document_number: String,
811 company_code: String,
812 posting_date: NaiveDate,
813 description: String,
814 ) -> Self {
815 let mut header = JournalEntryHeader::new(company_code, posting_date);
816 header.header_text = Some(description);
817 Self {
818 header,
819 lines: SmallVec::new(),
820 }
821 }
822
823 #[inline]
825 pub fn add_line(&mut self, line: JournalEntryLine) {
826 self.lines.push(line);
827 }
828
829 pub fn total_debit(&self) -> Decimal {
831 self.lines.iter().map(|l| l.debit_amount).sum()
832 }
833
834 pub fn total_credit(&self) -> Decimal {
836 self.lines.iter().map(|l| l.credit_amount).sum()
837 }
838
839 pub fn is_balanced(&self) -> bool {
841 self.total_debit() == self.total_credit()
842 }
843
844 pub fn balance_difference(&self) -> Decimal {
846 self.total_debit() - self.total_credit()
847 }
848
849 pub fn line_count(&self) -> usize {
851 self.lines.len()
852 }
853
854 pub fn has_even_line_count(&self) -> bool {
856 self.lines.len().is_multiple_of(2)
857 }
858
859 pub fn debit_credit_counts(&self) -> (usize, usize) {
861 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
862 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
863 (debits, credits)
864 }
865
866 pub fn has_equal_debit_credit_counts(&self) -> bool {
868 let (d, c) = self.debit_credit_counts();
869 d == c
870 }
871
872 pub fn unique_accounts(&self) -> Vec<&str> {
874 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
875 accounts.sort();
876 accounts.dedup();
877 accounts
878 }
879
880 pub fn has_suspense_posting(&self) -> bool {
882 self.lines.iter().any(|l| l.is_suspense)
883 }
884
885 pub fn company_code(&self) -> &str {
889 &self.header.company_code
890 }
891
892 pub fn document_number(&self) -> String {
894 self.header.document_id.to_string()
895 }
896
897 pub fn posting_date(&self) -> NaiveDate {
899 self.header.posting_date
900 }
901
902 pub fn document_date(&self) -> NaiveDate {
904 self.header.document_date
905 }
906
907 pub fn fiscal_year(&self) -> u16 {
909 self.header.fiscal_year
910 }
911
912 pub fn fiscal_period(&self) -> u8 {
914 self.header.fiscal_period
915 }
916
917 pub fn currency(&self) -> &str {
919 &self.header.currency
920 }
921
922 pub fn is_fraud(&self) -> bool {
924 self.header.is_fraud
925 }
926
927 pub fn has_sod_violation(&self) -> bool {
929 self.header.sod_violation
930 }
931
932 pub fn description(&self) -> Option<&str> {
934 self.header.header_text.as_deref()
935 }
936
937 pub fn set_description(&mut self, description: String) {
939 self.header.header_text = Some(description);
940 }
941}
942
943#[cfg(test)]
944#[allow(clippy::unwrap_used)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn test_balanced_entry() {
950 let header = JournalEntryHeader::new(
951 "1000".to_string(),
952 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
953 );
954 let mut entry = JournalEntry::new(header);
955
956 entry.add_line(JournalEntryLine::debit(
957 entry.header.document_id,
958 1,
959 "100000".to_string(),
960 Decimal::from(1000),
961 ));
962 entry.add_line(JournalEntryLine::credit(
963 entry.header.document_id,
964 2,
965 "200000".to_string(),
966 Decimal::from(1000),
967 ));
968
969 assert!(entry.is_balanced());
970 assert_eq!(entry.line_count(), 2);
971 assert!(entry.has_even_line_count());
972 assert!(entry.has_equal_debit_credit_counts());
973 }
974
975 #[test]
976 fn test_unbalanced_entry() {
977 let header = JournalEntryHeader::new(
978 "1000".to_string(),
979 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
980 );
981 let mut entry = JournalEntry::new(header);
982
983 entry.add_line(JournalEntryLine::debit(
984 entry.header.document_id,
985 1,
986 "100000".to_string(),
987 Decimal::from(1000),
988 ));
989 entry.add_line(JournalEntryLine::credit(
990 entry.header.document_id,
991 2,
992 "200000".to_string(),
993 Decimal::from(500),
994 ));
995
996 assert!(!entry.is_balanced());
997 assert_eq!(entry.balance_difference(), Decimal::from(500));
998 }
999}