1use chrono::{DateTime, NaiveDate, 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)]
312 pub control_ids: Vec<String>,
313
314 #[serde(default)]
316 pub sox_relevant: bool,
317
318 #[serde(default)]
320 pub control_status: super::internal_control::ControlStatus,
321
322 #[serde(default)]
324 pub sod_violation: bool,
325
326 #[serde(default)]
328 pub sod_conflict_type: Option<super::sod::SodConflictType>,
329
330 #[serde(default)]
332 pub is_elimination: bool,
333
334 #[serde(default)]
337 pub approval_workflow: Option<ApprovalWorkflow>,
338
339 #[serde(default)]
342 pub source_document: Option<DocumentRef>,
343 #[serde(default)]
345 pub approved_by: Option<String>,
346 #[serde(default)]
348 pub approval_date: Option<NaiveDate>,
349
350 #[serde(default)]
353 pub ocpm_event_ids: Vec<Uuid>,
354
355 #[serde(default)]
357 pub ocpm_object_ids: Vec<Uuid>,
358
359 #[serde(default)]
361 pub ocpm_case_id: Option<Uuid>,
362}
363
364impl JournalEntryHeader {
365 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
367 Self {
368 document_id: Uuid::now_v7(),
369 company_code,
370 fiscal_year: posting_date.year() as u16,
371 fiscal_period: posting_date.month() as u8,
372 posting_date,
373 document_date: posting_date,
374 created_at: Utc::now(),
375 document_type: "SA".to_string(),
376 currency: "USD".to_string(),
377 exchange_rate: Decimal::ONE,
378 reference: None,
379 header_text: None,
380 created_by: "SYSTEM".to_string(),
381 user_persona: "automated_system".to_string(),
382 source: TransactionSource::Automated,
383 business_process: Some(BusinessProcess::R2R),
384 ledger: "0L".to_string(),
385 is_fraud: false,
386 fraud_type: None,
387 is_anomaly: false,
389 anomaly_id: None,
390 anomaly_type: None,
391 batch_id: None,
392 control_ids: Vec::new(),
394 sox_relevant: false,
395 control_status: super::internal_control::ControlStatus::default(),
396 sod_violation: false,
397 sod_conflict_type: None,
398 is_elimination: false,
400 approval_workflow: None,
402 source_document: None,
404 approved_by: None,
405 approval_date: None,
406 ocpm_event_ids: Vec::new(),
408 ocpm_object_ids: Vec::new(),
409 ocpm_case_id: None,
410 }
411 }
412
413 pub fn with_deterministic_id(
418 company_code: String,
419 posting_date: NaiveDate,
420 document_id: Uuid,
421 ) -> Self {
422 Self {
423 document_id,
424 company_code,
425 fiscal_year: posting_date.year() as u16,
426 fiscal_period: posting_date.month() as u8,
427 posting_date,
428 document_date: posting_date,
429 created_at: Utc::now(),
430 document_type: "SA".to_string(),
431 currency: "USD".to_string(),
432 exchange_rate: Decimal::ONE,
433 reference: None,
434 header_text: None,
435 created_by: "SYSTEM".to_string(),
436 user_persona: "automated_system".to_string(),
437 source: TransactionSource::Automated,
438 business_process: Some(BusinessProcess::R2R),
439 ledger: "0L".to_string(),
440 is_fraud: false,
441 fraud_type: None,
442 is_anomaly: false,
444 anomaly_id: None,
445 anomaly_type: None,
446 batch_id: None,
447 control_ids: Vec::new(),
449 sox_relevant: false,
450 control_status: super::internal_control::ControlStatus::default(),
451 sod_violation: false,
452 sod_conflict_type: None,
453 is_elimination: false,
455 approval_workflow: None,
457 source_document: None,
459 approved_by: None,
460 approval_date: None,
461 ocpm_event_ids: Vec::new(),
463 ocpm_object_ids: Vec::new(),
464 ocpm_case_id: None,
465 }
466 }
467}
468
469use chrono::Datelike;
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct JournalEntryLine {
477 pub document_id: Uuid,
479
480 pub line_number: u32,
482
483 pub gl_account: String,
485
486 #[serde(default)]
488 pub account_code: String,
489
490 #[serde(default)]
492 pub account_description: Option<String>,
493
494 #[serde(with = "rust_decimal::serde::str")]
496 pub debit_amount: Decimal,
497
498 #[serde(with = "rust_decimal::serde::str")]
500 pub credit_amount: Decimal,
501
502 #[serde(with = "rust_decimal::serde::str")]
504 pub local_amount: Decimal,
505
506 #[serde(default, with = "rust_decimal::serde::str_option")]
508 pub group_amount: Option<Decimal>,
509
510 pub cost_center: Option<String>,
512
513 pub profit_center: Option<String>,
515
516 pub segment: Option<String>,
518
519 pub functional_area: Option<String>,
521
522 pub line_text: Option<String>,
524
525 #[serde(default)]
527 pub text: Option<String>,
528
529 #[serde(default)]
531 pub reference: Option<String>,
532
533 #[serde(default)]
535 pub value_date: Option<NaiveDate>,
536
537 pub tax_code: Option<String>,
539
540 #[serde(default, with = "rust_decimal::serde::str_option")]
542 pub tax_amount: Option<Decimal>,
543
544 pub assignment: Option<String>,
546
547 pub offsetting_account: Option<String>,
549
550 pub is_suspense: bool,
552
553 pub trading_partner: Option<String>,
555
556 #[serde(default, with = "rust_decimal::serde::str_option")]
558 pub quantity: Option<Decimal>,
559
560 pub unit_of_measure: Option<String>,
562
563 #[serde(default)]
565 pub unit: Option<String>,
566
567 #[serde(default)]
569 pub project_code: Option<String>,
570
571 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub auxiliary_account_number: Option<String>,
575
576 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub auxiliary_account_label: Option<String>,
580
581 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub lettrage: Option<String>,
585
586 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub lettrage_date: Option<NaiveDate>,
590}
591
592impl JournalEntryLine {
593 #[inline]
595 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
596 Self {
597 document_id,
598 line_number,
599 gl_account: gl_account.clone(),
600 account_code: gl_account,
601 account_description: None,
602 debit_amount: amount,
603 credit_amount: Decimal::ZERO,
604 local_amount: amount,
605 group_amount: None,
606 cost_center: None,
607 profit_center: None,
608 segment: None,
609 functional_area: None,
610 line_text: None,
611 text: None,
612 reference: None,
613 value_date: None,
614 tax_code: None,
615 tax_amount: None,
616 assignment: None,
617 offsetting_account: None,
618 is_suspense: false,
619 trading_partner: None,
620 quantity: None,
621 unit_of_measure: None,
622 unit: None,
623 project_code: None,
624 auxiliary_account_number: None,
625 auxiliary_account_label: None,
626 lettrage: None,
627 lettrage_date: None,
628 }
629 }
630
631 #[inline]
633 pub fn credit(
634 document_id: Uuid,
635 line_number: u32,
636 gl_account: String,
637 amount: Decimal,
638 ) -> Self {
639 Self {
640 document_id,
641 line_number,
642 gl_account: gl_account.clone(),
643 account_code: gl_account,
644 account_description: None,
645 debit_amount: Decimal::ZERO,
646 credit_amount: amount,
647 local_amount: -amount,
648 group_amount: None,
649 cost_center: None,
650 profit_center: None,
651 segment: None,
652 functional_area: None,
653 line_text: None,
654 text: None,
655 reference: None,
656 value_date: None,
657 tax_code: None,
658 tax_amount: None,
659 assignment: None,
660 offsetting_account: None,
661 is_suspense: false,
662 trading_partner: None,
663 quantity: None,
664 unit_of_measure: None,
665 unit: None,
666 project_code: None,
667 auxiliary_account_number: None,
668 auxiliary_account_label: None,
669 lettrage: None,
670 lettrage_date: None,
671 }
672 }
673
674 #[inline]
676 pub fn is_debit(&self) -> bool {
677 self.debit_amount > Decimal::ZERO
678 }
679
680 #[inline]
682 pub fn is_credit(&self) -> bool {
683 self.credit_amount > Decimal::ZERO
684 }
685
686 #[inline]
688 pub fn signed_amount(&self) -> Decimal {
689 self.debit_amount - self.credit_amount
690 }
691
692 #[allow(clippy::misnamed_getters)]
696 pub fn account_code(&self) -> &str {
697 &self.gl_account
698 }
699
700 pub fn account_description(&self) -> &str {
702 ""
704 }
705}
706
707impl Default for JournalEntryLine {
708 fn default() -> Self {
709 Self {
710 document_id: Uuid::nil(),
711 line_number: 0,
712 gl_account: String::new(),
713 account_code: String::new(),
714 account_description: None,
715 debit_amount: Decimal::ZERO,
716 credit_amount: Decimal::ZERO,
717 local_amount: Decimal::ZERO,
718 group_amount: None,
719 cost_center: None,
720 profit_center: None,
721 segment: None,
722 functional_area: None,
723 line_text: None,
724 text: None,
725 reference: None,
726 value_date: None,
727 tax_code: None,
728 tax_amount: None,
729 assignment: None,
730 offsetting_account: None,
731 is_suspense: false,
732 trading_partner: None,
733 quantity: None,
734 unit_of_measure: None,
735 unit: None,
736 project_code: None,
737 auxiliary_account_number: None,
738 auxiliary_account_label: None,
739 lettrage: None,
740 lettrage_date: None,
741 }
742 }
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize)]
755pub struct JournalEntry {
756 pub header: JournalEntryHeader,
758 pub lines: SmallVec<[JournalEntryLine; 4]>,
761}
762
763impl JournalEntry {
764 pub fn new(header: JournalEntryHeader) -> Self {
766 Self {
767 header,
768 lines: SmallVec::new(),
769 }
770 }
771
772 pub fn new_simple(
778 _document_number: String,
779 company_code: String,
780 posting_date: NaiveDate,
781 description: String,
782 ) -> Self {
783 let mut header = JournalEntryHeader::new(company_code, posting_date);
784 header.header_text = Some(description);
785 Self {
786 header,
787 lines: SmallVec::new(),
788 }
789 }
790
791 #[inline]
793 pub fn add_line(&mut self, line: JournalEntryLine) {
794 self.lines.push(line);
795 }
796
797 pub fn total_debit(&self) -> Decimal {
799 self.lines.iter().map(|l| l.debit_amount).sum()
800 }
801
802 pub fn total_credit(&self) -> Decimal {
804 self.lines.iter().map(|l| l.credit_amount).sum()
805 }
806
807 pub fn is_balanced(&self) -> bool {
809 self.total_debit() == self.total_credit()
810 }
811
812 pub fn balance_difference(&self) -> Decimal {
814 self.total_debit() - self.total_credit()
815 }
816
817 pub fn line_count(&self) -> usize {
819 self.lines.len()
820 }
821
822 pub fn has_even_line_count(&self) -> bool {
824 self.lines.len().is_multiple_of(2)
825 }
826
827 pub fn debit_credit_counts(&self) -> (usize, usize) {
829 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
830 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
831 (debits, credits)
832 }
833
834 pub fn has_equal_debit_credit_counts(&self) -> bool {
836 let (d, c) = self.debit_credit_counts();
837 d == c
838 }
839
840 pub fn unique_accounts(&self) -> Vec<&str> {
842 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
843 accounts.sort();
844 accounts.dedup();
845 accounts
846 }
847
848 pub fn has_suspense_posting(&self) -> bool {
850 self.lines.iter().any(|l| l.is_suspense)
851 }
852
853 pub fn company_code(&self) -> &str {
857 &self.header.company_code
858 }
859
860 pub fn document_number(&self) -> String {
862 self.header.document_id.to_string()
863 }
864
865 pub fn posting_date(&self) -> NaiveDate {
867 self.header.posting_date
868 }
869
870 pub fn document_date(&self) -> NaiveDate {
872 self.header.document_date
873 }
874
875 pub fn fiscal_year(&self) -> u16 {
877 self.header.fiscal_year
878 }
879
880 pub fn fiscal_period(&self) -> u8 {
882 self.header.fiscal_period
883 }
884
885 pub fn currency(&self) -> &str {
887 &self.header.currency
888 }
889
890 pub fn is_fraud(&self) -> bool {
892 self.header.is_fraud
893 }
894
895 pub fn has_sod_violation(&self) -> bool {
897 self.header.sod_violation
898 }
899
900 pub fn description(&self) -> Option<&str> {
902 self.header.header_text.as_deref()
903 }
904
905 pub fn set_description(&mut self, description: String) {
907 self.header.header_text = Some(description);
908 }
909}
910
911#[cfg(test)]
912#[allow(clippy::unwrap_used)]
913mod tests {
914 use super::*;
915
916 #[test]
917 fn test_balanced_entry() {
918 let header = JournalEntryHeader::new(
919 "1000".to_string(),
920 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
921 );
922 let mut entry = JournalEntry::new(header);
923
924 entry.add_line(JournalEntryLine::debit(
925 entry.header.document_id,
926 1,
927 "100000".to_string(),
928 Decimal::from(1000),
929 ));
930 entry.add_line(JournalEntryLine::credit(
931 entry.header.document_id,
932 2,
933 "200000".to_string(),
934 Decimal::from(1000),
935 ));
936
937 assert!(entry.is_balanced());
938 assert_eq!(entry.line_count(), 2);
939 assert!(entry.has_even_line_count());
940 assert!(entry.has_equal_debit_credit_counts());
941 }
942
943 #[test]
944 fn test_unbalanced_entry() {
945 let header = JournalEntryHeader::new(
946 "1000".to_string(),
947 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
948 );
949 let mut entry = JournalEntry::new(header);
950
951 entry.add_line(JournalEntryLine::debit(
952 entry.header.document_id,
953 1,
954 "100000".to_string(),
955 Decimal::from(1000),
956 ));
957 entry.add_line(JournalEntryLine::credit(
958 entry.header.document_id,
959 2,
960 "200000".to_string(),
961 Decimal::from(500),
962 ));
963
964 assert!(!entry.is_balanced());
965 assert_eq!(entry.balance_difference(), Decimal::from(500));
966 }
967}