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
500impl JournalEntryLine {
501 #[inline]
503 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
504 Self {
505 document_id,
506 line_number,
507 gl_account: gl_account.clone(),
508 account_code: gl_account,
509 account_description: None,
510 debit_amount: amount,
511 credit_amount: Decimal::ZERO,
512 local_amount: amount,
513 group_amount: None,
514 cost_center: None,
515 profit_center: None,
516 segment: None,
517 functional_area: None,
518 line_text: None,
519 text: None,
520 reference: None,
521 value_date: None,
522 tax_code: None,
523 tax_amount: None,
524 assignment: None,
525 offsetting_account: None,
526 is_suspense: false,
527 trading_partner: None,
528 quantity: None,
529 unit_of_measure: None,
530 unit: None,
531 project_code: None,
532 }
533 }
534
535 #[inline]
537 pub fn credit(
538 document_id: Uuid,
539 line_number: u32,
540 gl_account: String,
541 amount: Decimal,
542 ) -> Self {
543 Self {
544 document_id,
545 line_number,
546 gl_account: gl_account.clone(),
547 account_code: gl_account,
548 account_description: None,
549 debit_amount: Decimal::ZERO,
550 credit_amount: amount,
551 local_amount: -amount,
552 group_amount: None,
553 cost_center: None,
554 profit_center: None,
555 segment: None,
556 functional_area: None,
557 line_text: None,
558 text: None,
559 reference: None,
560 value_date: None,
561 tax_code: None,
562 tax_amount: None,
563 assignment: None,
564 offsetting_account: None,
565 is_suspense: false,
566 trading_partner: None,
567 quantity: None,
568 unit_of_measure: None,
569 unit: None,
570 project_code: None,
571 }
572 }
573
574 #[inline]
576 pub fn is_debit(&self) -> bool {
577 self.debit_amount > Decimal::ZERO
578 }
579
580 #[inline]
582 pub fn is_credit(&self) -> bool {
583 self.credit_amount > Decimal::ZERO
584 }
585
586 #[inline]
588 pub fn signed_amount(&self) -> Decimal {
589 self.debit_amount - self.credit_amount
590 }
591
592 #[allow(clippy::misnamed_getters)]
596 pub fn account_code(&self) -> &str {
597 &self.gl_account
598 }
599
600 pub fn account_description(&self) -> &str {
602 ""
604 }
605}
606
607impl Default for JournalEntryLine {
608 fn default() -> Self {
609 Self {
610 document_id: Uuid::nil(),
611 line_number: 0,
612 gl_account: String::new(),
613 account_code: String::new(),
614 account_description: None,
615 debit_amount: Decimal::ZERO,
616 credit_amount: Decimal::ZERO,
617 local_amount: Decimal::ZERO,
618 group_amount: None,
619 cost_center: None,
620 profit_center: None,
621 segment: None,
622 functional_area: None,
623 line_text: None,
624 text: None,
625 reference: None,
626 value_date: None,
627 tax_code: None,
628 tax_amount: None,
629 assignment: None,
630 offsetting_account: None,
631 is_suspense: false,
632 trading_partner: None,
633 quantity: None,
634 unit_of_measure: None,
635 unit: None,
636 project_code: None,
637 }
638 }
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct JournalEntry {
652 pub header: JournalEntryHeader,
654 pub lines: SmallVec<[JournalEntryLine; 4]>,
657}
658
659impl JournalEntry {
660 pub fn new(header: JournalEntryHeader) -> Self {
662 Self {
663 header,
664 lines: SmallVec::new(),
665 }
666 }
667
668 pub fn new_simple(
674 _document_number: String,
675 company_code: String,
676 posting_date: NaiveDate,
677 description: String,
678 ) -> Self {
679 let mut header = JournalEntryHeader::new(company_code, posting_date);
680 header.header_text = Some(description);
681 Self {
682 header,
683 lines: SmallVec::new(),
684 }
685 }
686
687 #[inline]
689 pub fn add_line(&mut self, line: JournalEntryLine) {
690 self.lines.push(line);
691 }
692
693 pub fn total_debit(&self) -> Decimal {
695 self.lines.iter().map(|l| l.debit_amount).sum()
696 }
697
698 pub fn total_credit(&self) -> Decimal {
700 self.lines.iter().map(|l| l.credit_amount).sum()
701 }
702
703 pub fn is_balanced(&self) -> bool {
705 self.total_debit() == self.total_credit()
706 }
707
708 pub fn balance_difference(&self) -> Decimal {
710 self.total_debit() - self.total_credit()
711 }
712
713 pub fn line_count(&self) -> usize {
715 self.lines.len()
716 }
717
718 pub fn has_even_line_count(&self) -> bool {
720 self.lines.len().is_multiple_of(2)
721 }
722
723 pub fn debit_credit_counts(&self) -> (usize, usize) {
725 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
726 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
727 (debits, credits)
728 }
729
730 pub fn has_equal_debit_credit_counts(&self) -> bool {
732 let (d, c) = self.debit_credit_counts();
733 d == c
734 }
735
736 pub fn unique_accounts(&self) -> Vec<&str> {
738 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
739 accounts.sort();
740 accounts.dedup();
741 accounts
742 }
743
744 pub fn has_suspense_posting(&self) -> bool {
746 self.lines.iter().any(|l| l.is_suspense)
747 }
748
749 pub fn company_code(&self) -> &str {
753 &self.header.company_code
754 }
755
756 pub fn document_number(&self) -> String {
758 self.header.document_id.to_string()
759 }
760
761 pub fn posting_date(&self) -> NaiveDate {
763 self.header.posting_date
764 }
765
766 pub fn document_date(&self) -> NaiveDate {
768 self.header.document_date
769 }
770
771 pub fn fiscal_year(&self) -> u16 {
773 self.header.fiscal_year
774 }
775
776 pub fn fiscal_period(&self) -> u8 {
778 self.header.fiscal_period
779 }
780
781 pub fn currency(&self) -> &str {
783 &self.header.currency
784 }
785
786 pub fn is_fraud(&self) -> bool {
788 self.header.is_fraud
789 }
790
791 pub fn has_sod_violation(&self) -> bool {
793 self.header.sod_violation
794 }
795
796 pub fn description(&self) -> Option<&str> {
798 self.header.header_text.as_deref()
799 }
800
801 pub fn set_description(&mut self, description: String) {
803 self.header.header_text = Some(description);
804 }
805}
806
807#[cfg(test)]
808#[allow(clippy::unwrap_used)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn test_balanced_entry() {
814 let header = JournalEntryHeader::new(
815 "1000".to_string(),
816 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
817 );
818 let mut entry = JournalEntry::new(header);
819
820 entry.add_line(JournalEntryLine::debit(
821 entry.header.document_id,
822 1,
823 "100000".to_string(),
824 Decimal::from(1000),
825 ));
826 entry.add_line(JournalEntryLine::credit(
827 entry.header.document_id,
828 2,
829 "200000".to_string(),
830 Decimal::from(1000),
831 ));
832
833 assert!(entry.is_balanced());
834 assert_eq!(entry.line_count(), 2);
835 assert!(entry.has_even_line_count());
836 assert!(entry.has_equal_debit_credit_counts());
837 }
838
839 #[test]
840 fn test_unbalanced_entry() {
841 let header = JournalEntryHeader::new(
842 "1000".to_string(),
843 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
844 );
845 let mut entry = JournalEntry::new(header);
846
847 entry.add_line(JournalEntryLine::debit(
848 entry.header.document_id,
849 1,
850 "100000".to_string(),
851 Decimal::from(1000),
852 ));
853 entry.add_line(JournalEntryLine::credit(
854 entry.header.document_id,
855 2,
856 "200000".to_string(),
857 Decimal::from(500),
858 ));
859
860 assert!(!entry.is_balanced());
861 assert_eq!(entry.balance_difference(), Decimal::from(500));
862 }
863}