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, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum TransactionSource {
23 #[default]
25 Manual,
26 Automated,
28 Recurring,
30 Reversal,
32 Adjustment,
34 Statistical,
36}
37
38impl std::fmt::Display for TransactionSource {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Manual => write!(f, "manual"),
42 Self::Automated => write!(f, "automated"),
43 Self::Recurring => write!(f, "recurring"),
44 Self::Reversal => write!(f, "reversal"),
45 Self::Adjustment => write!(f, "adjustment"),
46 Self::Statistical => write!(f, "statistical"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
59#[serde(rename_all = "UPPERCASE")]
60pub enum BusinessProcess {
61 O2C,
63 P2P,
65 #[default]
67 R2R,
68 H2R,
70 A2R,
72 S2C,
74 #[serde(rename = "MFG")]
76 Mfg,
77 #[serde(rename = "BANK")]
79 Bank,
80 #[serde(rename = "AUDIT")]
82 Audit,
83 Treasury,
85 Tax,
87 Intercompany,
89 #[serde(rename = "PROJECT")]
91 ProjectAccounting,
92 #[serde(rename = "ESG")]
94 Esg,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub struct DocumentType {
102 pub code: String,
104 pub description: String,
106 pub business_process: BusinessProcess,
108 pub is_reversal: bool,
110}
111
112impl DocumentType {
113 pub fn gl_account() -> Self {
115 Self {
116 code: "SA".to_string(),
117 description: "G/L Account Document".to_string(),
118 business_process: BusinessProcess::R2R,
119 is_reversal: false,
120 }
121 }
122
123 pub fn vendor_invoice() -> Self {
125 Self {
126 code: "KR".to_string(),
127 description: "Vendor Invoice".to_string(),
128 business_process: BusinessProcess::P2P,
129 is_reversal: false,
130 }
131 }
132
133 pub fn customer_invoice() -> Self {
135 Self {
136 code: "DR".to_string(),
137 description: "Customer Invoice".to_string(),
138 business_process: BusinessProcess::O2C,
139 is_reversal: false,
140 }
141 }
142
143 pub fn vendor_payment() -> Self {
145 Self {
146 code: "KZ".to_string(),
147 description: "Vendor Payment".to_string(),
148 business_process: BusinessProcess::P2P,
149 is_reversal: false,
150 }
151 }
152
153 pub fn customer_payment() -> Self {
155 Self {
156 code: "DZ".to_string(),
157 description: "Customer Payment".to_string(),
158 business_process: BusinessProcess::O2C,
159 is_reversal: false,
160 }
161 }
162
163 pub fn asset_posting() -> Self {
165 Self {
166 code: "AA".to_string(),
167 description: "Asset Posting".to_string(),
168 business_process: BusinessProcess::A2R,
169 is_reversal: false,
170 }
171 }
172
173 pub fn payroll() -> Self {
175 Self {
176 code: "PR".to_string(),
177 description: "Payroll Document".to_string(),
178 business_process: BusinessProcess::H2R,
179 is_reversal: false,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct JournalEntryHeader {
190 pub document_id: Uuid,
192
193 pub company_code: String,
195
196 pub fiscal_year: u16,
198
199 pub fiscal_period: u8,
201
202 pub posting_date: NaiveDate,
204
205 pub document_date: NaiveDate,
207
208 pub created_at: DateTime<Utc>,
210
211 pub document_type: String,
213
214 pub currency: String,
216
217 #[serde(with = "rust_decimal::serde::str")]
219 pub exchange_rate: Decimal,
220
221 pub reference: Option<String>,
223
224 pub header_text: Option<String>,
226
227 pub created_by: String,
229
230 pub user_persona: String,
232
233 pub source: TransactionSource,
235
236 pub business_process: Option<BusinessProcess>,
238
239 pub ledger: String,
241
242 pub is_fraud: bool,
244
245 pub fraud_type: Option<FraudType>,
247
248 #[serde(default)]
251 pub is_anomaly: bool,
252
253 #[serde(default)]
255 pub anomaly_id: Option<String>,
256
257 #[serde(default)]
259 pub anomaly_type: Option<String>,
260
261 pub batch_id: Option<Uuid>,
263
264 #[serde(default)]
267 pub control_ids: Vec<String>,
268
269 #[serde(default)]
271 pub sox_relevant: bool,
272
273 #[serde(default)]
275 pub control_status: super::internal_control::ControlStatus,
276
277 #[serde(default)]
279 pub sod_violation: bool,
280
281 #[serde(default)]
283 pub sod_conflict_type: Option<super::sod::SodConflictType>,
284
285 #[serde(default)]
288 pub approval_workflow: Option<ApprovalWorkflow>,
289
290 #[serde(default)]
293 pub ocpm_event_ids: Vec<Uuid>,
294
295 #[serde(default)]
297 pub ocpm_object_ids: Vec<Uuid>,
298
299 #[serde(default)]
301 pub ocpm_case_id: Option<Uuid>,
302}
303
304impl JournalEntryHeader {
305 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
307 Self {
308 document_id: Uuid::now_v7(),
309 company_code,
310 fiscal_year: posting_date.year() as u16,
311 fiscal_period: posting_date.month() as u8,
312 posting_date,
313 document_date: posting_date,
314 created_at: Utc::now(),
315 document_type: "SA".to_string(),
316 currency: "USD".to_string(),
317 exchange_rate: Decimal::ONE,
318 reference: None,
319 header_text: None,
320 created_by: "SYSTEM".to_string(),
321 user_persona: "automated_system".to_string(),
322 source: TransactionSource::Automated,
323 business_process: Some(BusinessProcess::R2R),
324 ledger: "0L".to_string(),
325 is_fraud: false,
326 fraud_type: None,
327 is_anomaly: false,
329 anomaly_id: None,
330 anomaly_type: None,
331 batch_id: None,
332 control_ids: Vec::new(),
334 sox_relevant: false,
335 control_status: super::internal_control::ControlStatus::default(),
336 sod_violation: false,
337 sod_conflict_type: None,
338 approval_workflow: None,
340 ocpm_event_ids: Vec::new(),
342 ocpm_object_ids: Vec::new(),
343 ocpm_case_id: None,
344 }
345 }
346
347 pub fn with_deterministic_id(
352 company_code: String,
353 posting_date: NaiveDate,
354 document_id: Uuid,
355 ) -> Self {
356 Self {
357 document_id,
358 company_code,
359 fiscal_year: posting_date.year() as u16,
360 fiscal_period: posting_date.month() as u8,
361 posting_date,
362 document_date: posting_date,
363 created_at: Utc::now(),
364 document_type: "SA".to_string(),
365 currency: "USD".to_string(),
366 exchange_rate: Decimal::ONE,
367 reference: None,
368 header_text: None,
369 created_by: "SYSTEM".to_string(),
370 user_persona: "automated_system".to_string(),
371 source: TransactionSource::Automated,
372 business_process: Some(BusinessProcess::R2R),
373 ledger: "0L".to_string(),
374 is_fraud: false,
375 fraud_type: None,
376 is_anomaly: false,
378 anomaly_id: None,
379 anomaly_type: None,
380 batch_id: None,
381 control_ids: Vec::new(),
383 sox_relevant: false,
384 control_status: super::internal_control::ControlStatus::default(),
385 sod_violation: false,
386 sod_conflict_type: None,
387 approval_workflow: None,
389 ocpm_event_ids: Vec::new(),
391 ocpm_object_ids: Vec::new(),
392 ocpm_case_id: None,
393 }
394 }
395}
396
397use chrono::Datelike;
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct JournalEntryLine {
405 pub document_id: Uuid,
407
408 pub line_number: u32,
410
411 pub gl_account: String,
413
414 #[serde(default)]
416 pub account_code: String,
417
418 #[serde(default)]
420 pub account_description: Option<String>,
421
422 #[serde(with = "rust_decimal::serde::str")]
424 pub debit_amount: Decimal,
425
426 #[serde(with = "rust_decimal::serde::str")]
428 pub credit_amount: Decimal,
429
430 #[serde(with = "rust_decimal::serde::str")]
432 pub local_amount: Decimal,
433
434 #[serde(default, with = "rust_decimal::serde::str_option")]
436 pub group_amount: Option<Decimal>,
437
438 pub cost_center: Option<String>,
440
441 pub profit_center: Option<String>,
443
444 pub segment: Option<String>,
446
447 pub functional_area: Option<String>,
449
450 pub line_text: Option<String>,
452
453 #[serde(default)]
455 pub text: Option<String>,
456
457 #[serde(default)]
459 pub reference: Option<String>,
460
461 #[serde(default)]
463 pub value_date: Option<NaiveDate>,
464
465 pub tax_code: Option<String>,
467
468 #[serde(default, with = "rust_decimal::serde::str_option")]
470 pub tax_amount: Option<Decimal>,
471
472 pub assignment: Option<String>,
474
475 pub offsetting_account: Option<String>,
477
478 pub is_suspense: bool,
480
481 pub trading_partner: Option<String>,
483
484 #[serde(default, with = "rust_decimal::serde::str_option")]
486 pub quantity: Option<Decimal>,
487
488 pub unit_of_measure: Option<String>,
490
491 #[serde(default)]
493 pub unit: Option<String>,
494
495 #[serde(default)]
497 pub project_code: Option<String>,
498
499 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub auxiliary_account_number: Option<String>,
503
504 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub auxiliary_account_label: Option<String>,
508
509 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub lettrage: Option<String>,
513
514 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub lettrage_date: Option<NaiveDate>,
518}
519
520impl JournalEntryLine {
521 #[inline]
523 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
524 Self {
525 document_id,
526 line_number,
527 gl_account: gl_account.clone(),
528 account_code: gl_account,
529 account_description: None,
530 debit_amount: amount,
531 credit_amount: Decimal::ZERO,
532 local_amount: amount,
533 group_amount: None,
534 cost_center: None,
535 profit_center: None,
536 segment: None,
537 functional_area: None,
538 line_text: None,
539 text: None,
540 reference: None,
541 value_date: None,
542 tax_code: None,
543 tax_amount: None,
544 assignment: None,
545 offsetting_account: None,
546 is_suspense: false,
547 trading_partner: None,
548 quantity: None,
549 unit_of_measure: None,
550 unit: None,
551 project_code: None,
552 auxiliary_account_number: None,
553 auxiliary_account_label: None,
554 lettrage: None,
555 lettrage_date: None,
556 }
557 }
558
559 #[inline]
561 pub fn credit(
562 document_id: Uuid,
563 line_number: u32,
564 gl_account: String,
565 amount: Decimal,
566 ) -> Self {
567 Self {
568 document_id,
569 line_number,
570 gl_account: gl_account.clone(),
571 account_code: gl_account,
572 account_description: None,
573 debit_amount: Decimal::ZERO,
574 credit_amount: amount,
575 local_amount: -amount,
576 group_amount: None,
577 cost_center: None,
578 profit_center: None,
579 segment: None,
580 functional_area: None,
581 line_text: None,
582 text: None,
583 reference: None,
584 value_date: None,
585 tax_code: None,
586 tax_amount: None,
587 assignment: None,
588 offsetting_account: None,
589 is_suspense: false,
590 trading_partner: None,
591 quantity: None,
592 unit_of_measure: None,
593 unit: None,
594 project_code: None,
595 auxiliary_account_number: None,
596 auxiliary_account_label: None,
597 lettrage: None,
598 lettrage_date: None,
599 }
600 }
601
602 #[inline]
604 pub fn is_debit(&self) -> bool {
605 self.debit_amount > Decimal::ZERO
606 }
607
608 #[inline]
610 pub fn is_credit(&self) -> bool {
611 self.credit_amount > Decimal::ZERO
612 }
613
614 #[inline]
616 pub fn signed_amount(&self) -> Decimal {
617 self.debit_amount - self.credit_amount
618 }
619
620 #[allow(clippy::misnamed_getters)]
624 pub fn account_code(&self) -> &str {
625 &self.gl_account
626 }
627
628 pub fn account_description(&self) -> &str {
630 ""
632 }
633}
634
635impl Default for JournalEntryLine {
636 fn default() -> Self {
637 Self {
638 document_id: Uuid::nil(),
639 line_number: 0,
640 gl_account: String::new(),
641 account_code: String::new(),
642 account_description: None,
643 debit_amount: Decimal::ZERO,
644 credit_amount: Decimal::ZERO,
645 local_amount: Decimal::ZERO,
646 group_amount: None,
647 cost_center: None,
648 profit_center: None,
649 segment: None,
650 functional_area: None,
651 line_text: None,
652 text: None,
653 reference: None,
654 value_date: None,
655 tax_code: None,
656 tax_amount: None,
657 assignment: None,
658 offsetting_account: None,
659 is_suspense: false,
660 trading_partner: None,
661 quantity: None,
662 unit_of_measure: None,
663 unit: None,
664 project_code: None,
665 auxiliary_account_number: None,
666 auxiliary_account_label: None,
667 lettrage: None,
668 lettrage_date: None,
669 }
670 }
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct JournalEntry {
684 pub header: JournalEntryHeader,
686 pub lines: SmallVec<[JournalEntryLine; 4]>,
689}
690
691impl JournalEntry {
692 pub fn new(header: JournalEntryHeader) -> Self {
694 Self {
695 header,
696 lines: SmallVec::new(),
697 }
698 }
699
700 pub fn new_simple(
706 _document_number: String,
707 company_code: String,
708 posting_date: NaiveDate,
709 description: String,
710 ) -> Self {
711 let mut header = JournalEntryHeader::new(company_code, posting_date);
712 header.header_text = Some(description);
713 Self {
714 header,
715 lines: SmallVec::new(),
716 }
717 }
718
719 #[inline]
721 pub fn add_line(&mut self, line: JournalEntryLine) {
722 self.lines.push(line);
723 }
724
725 pub fn total_debit(&self) -> Decimal {
727 self.lines.iter().map(|l| l.debit_amount).sum()
728 }
729
730 pub fn total_credit(&self) -> Decimal {
732 self.lines.iter().map(|l| l.credit_amount).sum()
733 }
734
735 pub fn is_balanced(&self) -> bool {
737 self.total_debit() == self.total_credit()
738 }
739
740 pub fn balance_difference(&self) -> Decimal {
742 self.total_debit() - self.total_credit()
743 }
744
745 pub fn line_count(&self) -> usize {
747 self.lines.len()
748 }
749
750 pub fn has_even_line_count(&self) -> bool {
752 self.lines.len().is_multiple_of(2)
753 }
754
755 pub fn debit_credit_counts(&self) -> (usize, usize) {
757 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
758 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
759 (debits, credits)
760 }
761
762 pub fn has_equal_debit_credit_counts(&self) -> bool {
764 let (d, c) = self.debit_credit_counts();
765 d == c
766 }
767
768 pub fn unique_accounts(&self) -> Vec<&str> {
770 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
771 accounts.sort();
772 accounts.dedup();
773 accounts
774 }
775
776 pub fn has_suspense_posting(&self) -> bool {
778 self.lines.iter().any(|l| l.is_suspense)
779 }
780
781 pub fn company_code(&self) -> &str {
785 &self.header.company_code
786 }
787
788 pub fn document_number(&self) -> String {
790 self.header.document_id.to_string()
791 }
792
793 pub fn posting_date(&self) -> NaiveDate {
795 self.header.posting_date
796 }
797
798 pub fn document_date(&self) -> NaiveDate {
800 self.header.document_date
801 }
802
803 pub fn fiscal_year(&self) -> u16 {
805 self.header.fiscal_year
806 }
807
808 pub fn fiscal_period(&self) -> u8 {
810 self.header.fiscal_period
811 }
812
813 pub fn currency(&self) -> &str {
815 &self.header.currency
816 }
817
818 pub fn is_fraud(&self) -> bool {
820 self.header.is_fraud
821 }
822
823 pub fn has_sod_violation(&self) -> bool {
825 self.header.sod_violation
826 }
827
828 pub fn description(&self) -> Option<&str> {
830 self.header.header_text.as_deref()
831 }
832
833 pub fn set_description(&mut self, description: String) {
835 self.header.header_text = Some(description);
836 }
837}
838
839#[cfg(test)]
840#[allow(clippy::unwrap_used)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn test_balanced_entry() {
846 let header = JournalEntryHeader::new(
847 "1000".to_string(),
848 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
849 );
850 let mut entry = JournalEntry::new(header);
851
852 entry.add_line(JournalEntryLine::debit(
853 entry.header.document_id,
854 1,
855 "100000".to_string(),
856 Decimal::from(1000),
857 ));
858 entry.add_line(JournalEntryLine::credit(
859 entry.header.document_id,
860 2,
861 "200000".to_string(),
862 Decimal::from(1000),
863 ));
864
865 assert!(entry.is_balanced());
866 assert_eq!(entry.line_count(), 2);
867 assert!(entry.has_even_line_count());
868 assert!(entry.has_equal_debit_credit_counts());
869 }
870
871 #[test]
872 fn test_unbalanced_entry() {
873 let header = JournalEntryHeader::new(
874 "1000".to_string(),
875 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
876 );
877 let mut entry = JournalEntry::new(header);
878
879 entry.add_line(JournalEntryLine::debit(
880 entry.header.document_id,
881 1,
882 "100000".to_string(),
883 Decimal::from(1000),
884 ));
885 entry.add_line(JournalEntryLine::credit(
886 entry.header.document_id,
887 2,
888 "200000".to_string(),
889 Decimal::from(500),
890 ));
891
892 assert!(!entry.is_balanced());
893 assert_eq!(entry.balance_difference(), Decimal::from(500));
894 }
895}