1use chrono::{DateTime, NaiveDate, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use super::anomaly::FraudType;
13use super::approval::ApprovalWorkflow;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
20#[serde(rename_all = "snake_case")]
21pub enum TransactionSource {
22 #[default]
24 Manual,
25 Automated,
27 Recurring,
29 Reversal,
31 Adjustment,
33 Statistical,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
45#[serde(rename_all = "UPPERCASE")]
46pub enum BusinessProcess {
47 O2C,
49 P2P,
51 #[default]
53 R2R,
54 H2R,
56 A2R,
58 S2C,
60 #[serde(rename = "MFG")]
62 Mfg,
63 #[serde(rename = "BANK")]
65 Bank,
66 #[serde(rename = "AUDIT")]
68 Audit,
69 Treasury,
71 Tax,
73 Intercompany,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub struct DocumentType {
82 pub code: String,
84 pub description: String,
86 pub business_process: BusinessProcess,
88 pub is_reversal: bool,
90}
91
92impl DocumentType {
93 pub fn gl_account() -> Self {
95 Self {
96 code: "SA".to_string(),
97 description: "G/L Account Document".to_string(),
98 business_process: BusinessProcess::R2R,
99 is_reversal: false,
100 }
101 }
102
103 pub fn vendor_invoice() -> Self {
105 Self {
106 code: "KR".to_string(),
107 description: "Vendor Invoice".to_string(),
108 business_process: BusinessProcess::P2P,
109 is_reversal: false,
110 }
111 }
112
113 pub fn customer_invoice() -> Self {
115 Self {
116 code: "DR".to_string(),
117 description: "Customer Invoice".to_string(),
118 business_process: BusinessProcess::O2C,
119 is_reversal: false,
120 }
121 }
122
123 pub fn vendor_payment() -> Self {
125 Self {
126 code: "KZ".to_string(),
127 description: "Vendor Payment".to_string(),
128 business_process: BusinessProcess::P2P,
129 is_reversal: false,
130 }
131 }
132
133 pub fn customer_payment() -> Self {
135 Self {
136 code: "DZ".to_string(),
137 description: "Customer Payment".to_string(),
138 business_process: BusinessProcess::O2C,
139 is_reversal: false,
140 }
141 }
142
143 pub fn asset_posting() -> Self {
145 Self {
146 code: "AA".to_string(),
147 description: "Asset Posting".to_string(),
148 business_process: BusinessProcess::A2R,
149 is_reversal: false,
150 }
151 }
152
153 pub fn payroll() -> Self {
155 Self {
156 code: "PR".to_string(),
157 description: "Payroll Document".to_string(),
158 business_process: BusinessProcess::H2R,
159 is_reversal: false,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct JournalEntryHeader {
170 pub document_id: Uuid,
172
173 pub company_code: String,
175
176 pub fiscal_year: u16,
178
179 pub fiscal_period: u8,
181
182 pub posting_date: NaiveDate,
184
185 pub document_date: NaiveDate,
187
188 pub created_at: DateTime<Utc>,
190
191 pub document_type: String,
193
194 pub currency: String,
196
197 pub exchange_rate: Decimal,
199
200 pub reference: Option<String>,
202
203 pub header_text: Option<String>,
205
206 pub created_by: String,
208
209 pub user_persona: String,
211
212 pub source: TransactionSource,
214
215 pub business_process: Option<BusinessProcess>,
217
218 pub ledger: String,
220
221 pub is_fraud: bool,
223
224 pub fraud_type: Option<FraudType>,
226
227 #[serde(default)]
230 pub is_anomaly: bool,
231
232 #[serde(default)]
234 pub anomaly_id: Option<String>,
235
236 #[serde(default)]
238 pub anomaly_type: Option<String>,
239
240 pub batch_id: Option<Uuid>,
242
243 #[serde(default)]
246 pub control_ids: Vec<String>,
247
248 #[serde(default)]
250 pub sox_relevant: bool,
251
252 #[serde(default)]
254 pub control_status: super::internal_control::ControlStatus,
255
256 #[serde(default)]
258 pub sod_violation: bool,
259
260 #[serde(default)]
262 pub sod_conflict_type: Option<super::sod::SodConflictType>,
263
264 #[serde(default)]
267 pub approval_workflow: Option<ApprovalWorkflow>,
268
269 #[serde(default)]
272 pub ocpm_event_ids: Vec<Uuid>,
273
274 #[serde(default)]
276 pub ocpm_object_ids: Vec<Uuid>,
277
278 #[serde(default)]
280 pub ocpm_case_id: Option<Uuid>,
281}
282
283impl JournalEntryHeader {
284 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
286 Self {
287 document_id: Uuid::now_v7(),
288 company_code,
289 fiscal_year: posting_date.year() as u16,
290 fiscal_period: posting_date.month() as u8,
291 posting_date,
292 document_date: posting_date,
293 created_at: Utc::now(),
294 document_type: "SA".to_string(),
295 currency: "USD".to_string(),
296 exchange_rate: Decimal::ONE,
297 reference: None,
298 header_text: None,
299 created_by: "SYSTEM".to_string(),
300 user_persona: "automated_system".to_string(),
301 source: TransactionSource::Automated,
302 business_process: Some(BusinessProcess::R2R),
303 ledger: "0L".to_string(),
304 is_fraud: false,
305 fraud_type: None,
306 is_anomaly: false,
308 anomaly_id: None,
309 anomaly_type: None,
310 batch_id: None,
311 control_ids: Vec::new(),
313 sox_relevant: false,
314 control_status: super::internal_control::ControlStatus::default(),
315 sod_violation: false,
316 sod_conflict_type: None,
317 approval_workflow: None,
319 ocpm_event_ids: Vec::new(),
321 ocpm_object_ids: Vec::new(),
322 ocpm_case_id: None,
323 }
324 }
325
326 pub fn with_deterministic_id(
331 company_code: String,
332 posting_date: NaiveDate,
333 document_id: Uuid,
334 ) -> Self {
335 Self {
336 document_id,
337 company_code,
338 fiscal_year: posting_date.year() as u16,
339 fiscal_period: posting_date.month() as u8,
340 posting_date,
341 document_date: posting_date,
342 created_at: Utc::now(),
343 document_type: "SA".to_string(),
344 currency: "USD".to_string(),
345 exchange_rate: Decimal::ONE,
346 reference: None,
347 header_text: None,
348 created_by: "SYSTEM".to_string(),
349 user_persona: "automated_system".to_string(),
350 source: TransactionSource::Automated,
351 business_process: Some(BusinessProcess::R2R),
352 ledger: "0L".to_string(),
353 is_fraud: false,
354 fraud_type: None,
355 is_anomaly: false,
357 anomaly_id: None,
358 anomaly_type: None,
359 batch_id: None,
360 control_ids: Vec::new(),
362 sox_relevant: false,
363 control_status: super::internal_control::ControlStatus::default(),
364 sod_violation: false,
365 sod_conflict_type: None,
366 approval_workflow: None,
368 ocpm_event_ids: Vec::new(),
370 ocpm_object_ids: Vec::new(),
371 ocpm_case_id: None,
372 }
373 }
374}
375
376use chrono::Datelike;
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct JournalEntryLine {
384 pub document_id: Uuid,
386
387 pub line_number: u32,
389
390 pub gl_account: String,
392
393 #[serde(default)]
395 pub account_code: String,
396
397 #[serde(default)]
399 pub account_description: Option<String>,
400
401 #[serde(with = "rust_decimal::serde::str")]
403 pub debit_amount: Decimal,
404
405 #[serde(with = "rust_decimal::serde::str")]
407 pub credit_amount: Decimal,
408
409 #[serde(with = "rust_decimal::serde::str")]
411 pub local_amount: Decimal,
412
413 #[serde(default, with = "rust_decimal::serde::str_option")]
415 pub group_amount: Option<Decimal>,
416
417 pub cost_center: Option<String>,
419
420 pub profit_center: Option<String>,
422
423 pub segment: Option<String>,
425
426 pub functional_area: Option<String>,
428
429 pub line_text: Option<String>,
431
432 #[serde(default)]
434 pub text: Option<String>,
435
436 #[serde(default)]
438 pub reference: Option<String>,
439
440 #[serde(default)]
442 pub value_date: Option<NaiveDate>,
443
444 pub tax_code: Option<String>,
446
447 #[serde(default, with = "rust_decimal::serde::str_option")]
449 pub tax_amount: Option<Decimal>,
450
451 pub assignment: Option<String>,
453
454 pub offsetting_account: Option<String>,
456
457 pub is_suspense: bool,
459
460 pub trading_partner: Option<String>,
462
463 #[serde(default, with = "rust_decimal::serde::str_option")]
465 pub quantity: Option<Decimal>,
466
467 pub unit_of_measure: Option<String>,
469
470 #[serde(default)]
472 pub unit: Option<String>,
473
474 #[serde(default)]
476 pub project_code: Option<String>,
477}
478
479impl JournalEntryLine {
480 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
482 Self {
483 document_id,
484 line_number,
485 gl_account: gl_account.clone(),
486 account_code: gl_account,
487 account_description: None,
488 debit_amount: amount,
489 credit_amount: Decimal::ZERO,
490 local_amount: amount,
491 group_amount: None,
492 cost_center: None,
493 profit_center: None,
494 segment: None,
495 functional_area: None,
496 line_text: None,
497 text: None,
498 reference: None,
499 value_date: None,
500 tax_code: None,
501 tax_amount: None,
502 assignment: None,
503 offsetting_account: None,
504 is_suspense: false,
505 trading_partner: None,
506 quantity: None,
507 unit_of_measure: None,
508 unit: None,
509 project_code: None,
510 }
511 }
512
513 pub fn credit(
515 document_id: Uuid,
516 line_number: u32,
517 gl_account: String,
518 amount: Decimal,
519 ) -> Self {
520 Self {
521 document_id,
522 line_number,
523 gl_account: gl_account.clone(),
524 account_code: gl_account,
525 account_description: None,
526 debit_amount: Decimal::ZERO,
527 credit_amount: amount,
528 local_amount: -amount,
529 group_amount: None,
530 cost_center: None,
531 profit_center: None,
532 segment: None,
533 functional_area: None,
534 line_text: None,
535 text: None,
536 reference: None,
537 value_date: None,
538 tax_code: None,
539 tax_amount: None,
540 assignment: None,
541 offsetting_account: None,
542 is_suspense: false,
543 trading_partner: None,
544 quantity: None,
545 unit_of_measure: None,
546 unit: None,
547 project_code: None,
548 }
549 }
550
551 pub fn is_debit(&self) -> bool {
553 self.debit_amount > Decimal::ZERO
554 }
555
556 pub fn is_credit(&self) -> bool {
558 self.credit_amount > Decimal::ZERO
559 }
560
561 pub fn signed_amount(&self) -> Decimal {
563 self.debit_amount - self.credit_amount
564 }
565
566 #[allow(clippy::misnamed_getters)]
570 pub fn account_code(&self) -> &str {
571 &self.gl_account
572 }
573
574 pub fn account_description(&self) -> &str {
576 ""
578 }
579}
580
581impl Default for JournalEntryLine {
582 fn default() -> Self {
583 Self {
584 document_id: Uuid::nil(),
585 line_number: 0,
586 gl_account: String::new(),
587 account_code: String::new(),
588 account_description: None,
589 debit_amount: Decimal::ZERO,
590 credit_amount: Decimal::ZERO,
591 local_amount: Decimal::ZERO,
592 group_amount: None,
593 cost_center: None,
594 profit_center: None,
595 segment: None,
596 functional_area: None,
597 line_text: None,
598 text: None,
599 reference: None,
600 value_date: None,
601 tax_code: None,
602 tax_amount: None,
603 assignment: None,
604 offsetting_account: None,
605 is_suspense: false,
606 trading_partner: None,
607 quantity: None,
608 unit_of_measure: None,
609 unit: None,
610 project_code: None,
611 }
612 }
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct JournalEntry {
621 pub header: JournalEntryHeader,
623 pub lines: Vec<JournalEntryLine>,
625}
626
627impl JournalEntry {
628 pub fn new(header: JournalEntryHeader) -> Self {
630 Self {
631 header,
632 lines: Vec::new(),
633 }
634 }
635
636 pub fn new_simple(
642 _document_number: String,
643 company_code: String,
644 posting_date: NaiveDate,
645 description: String,
646 ) -> Self {
647 let mut header = JournalEntryHeader::new(company_code, posting_date);
648 header.header_text = Some(description);
649 Self {
650 header,
651 lines: Vec::new(),
652 }
653 }
654
655 pub fn add_line(&mut self, line: JournalEntryLine) {
657 self.lines.push(line);
658 }
659
660 pub fn total_debit(&self) -> Decimal {
662 self.lines.iter().map(|l| l.debit_amount).sum()
663 }
664
665 pub fn total_credit(&self) -> Decimal {
667 self.lines.iter().map(|l| l.credit_amount).sum()
668 }
669
670 pub fn is_balanced(&self) -> bool {
672 self.total_debit() == self.total_credit()
673 }
674
675 pub fn balance_difference(&self) -> Decimal {
677 self.total_debit() - self.total_credit()
678 }
679
680 pub fn line_count(&self) -> usize {
682 self.lines.len()
683 }
684
685 pub fn has_even_line_count(&self) -> bool {
687 self.lines.len().is_multiple_of(2)
688 }
689
690 pub fn debit_credit_counts(&self) -> (usize, usize) {
692 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
693 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
694 (debits, credits)
695 }
696
697 pub fn has_equal_debit_credit_counts(&self) -> bool {
699 let (d, c) = self.debit_credit_counts();
700 d == c
701 }
702
703 pub fn unique_accounts(&self) -> Vec<&str> {
705 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
706 accounts.sort();
707 accounts.dedup();
708 accounts
709 }
710
711 pub fn has_suspense_posting(&self) -> bool {
713 self.lines.iter().any(|l| l.is_suspense)
714 }
715
716 pub fn company_code(&self) -> &str {
720 &self.header.company_code
721 }
722
723 pub fn document_number(&self) -> String {
725 self.header.document_id.to_string()
726 }
727
728 pub fn posting_date(&self) -> NaiveDate {
730 self.header.posting_date
731 }
732
733 pub fn document_date(&self) -> NaiveDate {
735 self.header.document_date
736 }
737
738 pub fn fiscal_year(&self) -> u16 {
740 self.header.fiscal_year
741 }
742
743 pub fn fiscal_period(&self) -> u8 {
745 self.header.fiscal_period
746 }
747
748 pub fn currency(&self) -> &str {
750 &self.header.currency
751 }
752
753 pub fn is_fraud(&self) -> bool {
755 self.header.is_fraud
756 }
757
758 pub fn has_sod_violation(&self) -> bool {
760 self.header.sod_violation
761 }
762
763 pub fn description(&self) -> Option<&str> {
765 self.header.header_text.as_deref()
766 }
767
768 pub fn set_description(&mut self, description: String) {
770 self.header.header_text = Some(description);
771 }
772}
773
774#[cfg(test)]
775#[allow(clippy::unwrap_used)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn test_balanced_entry() {
781 let header = JournalEntryHeader::new(
782 "1000".to_string(),
783 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
784 );
785 let mut entry = JournalEntry::new(header);
786
787 entry.add_line(JournalEntryLine::debit(
788 entry.header.document_id,
789 1,
790 "100000".to_string(),
791 Decimal::from(1000),
792 ));
793 entry.add_line(JournalEntryLine::credit(
794 entry.header.document_id,
795 2,
796 "200000".to_string(),
797 Decimal::from(1000),
798 ));
799
800 assert!(entry.is_balanced());
801 assert_eq!(entry.line_count(), 2);
802 assert!(entry.has_even_line_count());
803 assert!(entry.has_equal_debit_credit_counts());
804 }
805
806 #[test]
807 fn test_unbalanced_entry() {
808 let header = JournalEntryHeader::new(
809 "1000".to_string(),
810 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
811 );
812 let mut entry = JournalEntry::new(header);
813
814 entry.add_line(JournalEntryLine::debit(
815 entry.header.document_id,
816 1,
817 "100000".to_string(),
818 Decimal::from(1000),
819 ));
820 entry.add_line(JournalEntryLine::credit(
821 entry.header.document_id,
822 2,
823 "200000".to_string(),
824 Decimal::from(500),
825 ));
826
827 assert!(!entry.is_balanced());
828 assert_eq!(entry.balance_difference(), Decimal::from(500));
829 }
830}