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
37impl std::fmt::Display for TransactionSource {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::Manual => write!(f, "manual"),
41 Self::Automated => write!(f, "automated"),
42 Self::Recurring => write!(f, "recurring"),
43 Self::Reversal => write!(f, "reversal"),
44 Self::Adjustment => write!(f, "adjustment"),
45 Self::Statistical => write!(f, "statistical"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "UPPERCASE")]
59pub enum BusinessProcess {
60 O2C,
62 P2P,
64 #[default]
66 R2R,
67 H2R,
69 A2R,
71 S2C,
73 #[serde(rename = "MFG")]
75 Mfg,
76 #[serde(rename = "BANK")]
78 Bank,
79 #[serde(rename = "AUDIT")]
81 Audit,
82 Treasury,
84 Tax,
86 Intercompany,
88 #[serde(rename = "PROJECT")]
90 ProjectAccounting,
91 #[serde(rename = "ESG")]
93 Esg,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct DocumentType {
101 pub code: String,
103 pub description: String,
105 pub business_process: BusinessProcess,
107 pub is_reversal: bool,
109}
110
111impl DocumentType {
112 pub fn gl_account() -> Self {
114 Self {
115 code: "SA".to_string(),
116 description: "G/L Account Document".to_string(),
117 business_process: BusinessProcess::R2R,
118 is_reversal: false,
119 }
120 }
121
122 pub fn vendor_invoice() -> Self {
124 Self {
125 code: "KR".to_string(),
126 description: "Vendor Invoice".to_string(),
127 business_process: BusinessProcess::P2P,
128 is_reversal: false,
129 }
130 }
131
132 pub fn customer_invoice() -> Self {
134 Self {
135 code: "DR".to_string(),
136 description: "Customer Invoice".to_string(),
137 business_process: BusinessProcess::O2C,
138 is_reversal: false,
139 }
140 }
141
142 pub fn vendor_payment() -> Self {
144 Self {
145 code: "KZ".to_string(),
146 description: "Vendor Payment".to_string(),
147 business_process: BusinessProcess::P2P,
148 is_reversal: false,
149 }
150 }
151
152 pub fn customer_payment() -> Self {
154 Self {
155 code: "DZ".to_string(),
156 description: "Customer Payment".to_string(),
157 business_process: BusinessProcess::O2C,
158 is_reversal: false,
159 }
160 }
161
162 pub fn asset_posting() -> Self {
164 Self {
165 code: "AA".to_string(),
166 description: "Asset Posting".to_string(),
167 business_process: BusinessProcess::A2R,
168 is_reversal: false,
169 }
170 }
171
172 pub fn payroll() -> Self {
174 Self {
175 code: "PR".to_string(),
176 description: "Payroll Document".to_string(),
177 business_process: BusinessProcess::H2R,
178 is_reversal: false,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct JournalEntryHeader {
189 pub document_id: Uuid,
191
192 pub company_code: String,
194
195 pub fiscal_year: u16,
197
198 pub fiscal_period: u8,
200
201 pub posting_date: NaiveDate,
203
204 pub document_date: NaiveDate,
206
207 pub created_at: DateTime<Utc>,
209
210 pub document_type: String,
212
213 pub currency: String,
215
216 #[serde(with = "rust_decimal::serde::str")]
218 pub exchange_rate: Decimal,
219
220 pub reference: Option<String>,
222
223 pub header_text: Option<String>,
225
226 pub created_by: String,
228
229 pub user_persona: String,
231
232 pub source: TransactionSource,
234
235 pub business_process: Option<BusinessProcess>,
237
238 pub ledger: String,
240
241 pub is_fraud: bool,
243
244 pub fraud_type: Option<FraudType>,
246
247 #[serde(default)]
250 pub is_anomaly: bool,
251
252 #[serde(default)]
254 pub anomaly_id: Option<String>,
255
256 #[serde(default)]
258 pub anomaly_type: Option<String>,
259
260 pub batch_id: Option<Uuid>,
262
263 #[serde(default)]
266 pub control_ids: Vec<String>,
267
268 #[serde(default)]
270 pub sox_relevant: bool,
271
272 #[serde(default)]
274 pub control_status: super::internal_control::ControlStatus,
275
276 #[serde(default)]
278 pub sod_violation: bool,
279
280 #[serde(default)]
282 pub sod_conflict_type: Option<super::sod::SodConflictType>,
283
284 #[serde(default)]
287 pub approval_workflow: Option<ApprovalWorkflow>,
288
289 #[serde(default)]
292 pub ocpm_event_ids: Vec<Uuid>,
293
294 #[serde(default)]
296 pub ocpm_object_ids: Vec<Uuid>,
297
298 #[serde(default)]
300 pub ocpm_case_id: Option<Uuid>,
301}
302
303impl JournalEntryHeader {
304 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
306 Self {
307 document_id: Uuid::now_v7(),
308 company_code,
309 fiscal_year: posting_date.year() as u16,
310 fiscal_period: posting_date.month() as u8,
311 posting_date,
312 document_date: posting_date,
313 created_at: Utc::now(),
314 document_type: "SA".to_string(),
315 currency: "USD".to_string(),
316 exchange_rate: Decimal::ONE,
317 reference: None,
318 header_text: None,
319 created_by: "SYSTEM".to_string(),
320 user_persona: "automated_system".to_string(),
321 source: TransactionSource::Automated,
322 business_process: Some(BusinessProcess::R2R),
323 ledger: "0L".to_string(),
324 is_fraud: false,
325 fraud_type: None,
326 is_anomaly: false,
328 anomaly_id: None,
329 anomaly_type: None,
330 batch_id: None,
331 control_ids: Vec::new(),
333 sox_relevant: false,
334 control_status: super::internal_control::ControlStatus::default(),
335 sod_violation: false,
336 sod_conflict_type: None,
337 approval_workflow: None,
339 ocpm_event_ids: Vec::new(),
341 ocpm_object_ids: Vec::new(),
342 ocpm_case_id: None,
343 }
344 }
345
346 pub fn with_deterministic_id(
351 company_code: String,
352 posting_date: NaiveDate,
353 document_id: Uuid,
354 ) -> Self {
355 Self {
356 document_id,
357 company_code,
358 fiscal_year: posting_date.year() as u16,
359 fiscal_period: posting_date.month() as u8,
360 posting_date,
361 document_date: posting_date,
362 created_at: Utc::now(),
363 document_type: "SA".to_string(),
364 currency: "USD".to_string(),
365 exchange_rate: Decimal::ONE,
366 reference: None,
367 header_text: None,
368 created_by: "SYSTEM".to_string(),
369 user_persona: "automated_system".to_string(),
370 source: TransactionSource::Automated,
371 business_process: Some(BusinessProcess::R2R),
372 ledger: "0L".to_string(),
373 is_fraud: false,
374 fraud_type: None,
375 is_anomaly: false,
377 anomaly_id: None,
378 anomaly_type: None,
379 batch_id: None,
380 control_ids: Vec::new(),
382 sox_relevant: false,
383 control_status: super::internal_control::ControlStatus::default(),
384 sod_violation: false,
385 sod_conflict_type: None,
386 approval_workflow: None,
388 ocpm_event_ids: Vec::new(),
390 ocpm_object_ids: Vec::new(),
391 ocpm_case_id: None,
392 }
393 }
394}
395
396use chrono::Datelike;
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct JournalEntryLine {
404 pub document_id: Uuid,
406
407 pub line_number: u32,
409
410 pub gl_account: String,
412
413 #[serde(default)]
415 pub account_code: String,
416
417 #[serde(default)]
419 pub account_description: Option<String>,
420
421 #[serde(with = "rust_decimal::serde::str")]
423 pub debit_amount: Decimal,
424
425 #[serde(with = "rust_decimal::serde::str")]
427 pub credit_amount: Decimal,
428
429 #[serde(with = "rust_decimal::serde::str")]
431 pub local_amount: Decimal,
432
433 #[serde(default, with = "rust_decimal::serde::str_option")]
435 pub group_amount: Option<Decimal>,
436
437 pub cost_center: Option<String>,
439
440 pub profit_center: Option<String>,
442
443 pub segment: Option<String>,
445
446 pub functional_area: Option<String>,
448
449 pub line_text: Option<String>,
451
452 #[serde(default)]
454 pub text: Option<String>,
455
456 #[serde(default)]
458 pub reference: Option<String>,
459
460 #[serde(default)]
462 pub value_date: Option<NaiveDate>,
463
464 pub tax_code: Option<String>,
466
467 #[serde(default, with = "rust_decimal::serde::str_option")]
469 pub tax_amount: Option<Decimal>,
470
471 pub assignment: Option<String>,
473
474 pub offsetting_account: Option<String>,
476
477 pub is_suspense: bool,
479
480 pub trading_partner: Option<String>,
482
483 #[serde(default, with = "rust_decimal::serde::str_option")]
485 pub quantity: Option<Decimal>,
486
487 pub unit_of_measure: Option<String>,
489
490 #[serde(default)]
492 pub unit: Option<String>,
493
494 #[serde(default)]
496 pub project_code: Option<String>,
497}
498
499impl JournalEntryLine {
500 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
502 Self {
503 document_id,
504 line_number,
505 gl_account: gl_account.clone(),
506 account_code: gl_account,
507 account_description: None,
508 debit_amount: amount,
509 credit_amount: Decimal::ZERO,
510 local_amount: amount,
511 group_amount: None,
512 cost_center: None,
513 profit_center: None,
514 segment: None,
515 functional_area: None,
516 line_text: None,
517 text: None,
518 reference: None,
519 value_date: None,
520 tax_code: None,
521 tax_amount: None,
522 assignment: None,
523 offsetting_account: None,
524 is_suspense: false,
525 trading_partner: None,
526 quantity: None,
527 unit_of_measure: None,
528 unit: None,
529 project_code: None,
530 }
531 }
532
533 pub fn credit(
535 document_id: Uuid,
536 line_number: u32,
537 gl_account: String,
538 amount: Decimal,
539 ) -> Self {
540 Self {
541 document_id,
542 line_number,
543 gl_account: gl_account.clone(),
544 account_code: gl_account,
545 account_description: None,
546 debit_amount: Decimal::ZERO,
547 credit_amount: amount,
548 local_amount: -amount,
549 group_amount: None,
550 cost_center: None,
551 profit_center: None,
552 segment: None,
553 functional_area: None,
554 line_text: None,
555 text: None,
556 reference: None,
557 value_date: None,
558 tax_code: None,
559 tax_amount: None,
560 assignment: None,
561 offsetting_account: None,
562 is_suspense: false,
563 trading_partner: None,
564 quantity: None,
565 unit_of_measure: None,
566 unit: None,
567 project_code: None,
568 }
569 }
570
571 pub fn is_debit(&self) -> bool {
573 self.debit_amount > Decimal::ZERO
574 }
575
576 pub fn is_credit(&self) -> bool {
578 self.credit_amount > Decimal::ZERO
579 }
580
581 pub fn signed_amount(&self) -> Decimal {
583 self.debit_amount - self.credit_amount
584 }
585
586 #[allow(clippy::misnamed_getters)]
590 pub fn account_code(&self) -> &str {
591 &self.gl_account
592 }
593
594 pub fn account_description(&self) -> &str {
596 ""
598 }
599}
600
601impl Default for JournalEntryLine {
602 fn default() -> Self {
603 Self {
604 document_id: Uuid::nil(),
605 line_number: 0,
606 gl_account: String::new(),
607 account_code: String::new(),
608 account_description: None,
609 debit_amount: Decimal::ZERO,
610 credit_amount: Decimal::ZERO,
611 local_amount: Decimal::ZERO,
612 group_amount: None,
613 cost_center: None,
614 profit_center: None,
615 segment: None,
616 functional_area: None,
617 line_text: None,
618 text: None,
619 reference: None,
620 value_date: None,
621 tax_code: None,
622 tax_amount: None,
623 assignment: None,
624 offsetting_account: None,
625 is_suspense: false,
626 trading_partner: None,
627 quantity: None,
628 unit_of_measure: None,
629 unit: None,
630 project_code: None,
631 }
632 }
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct JournalEntry {
641 pub header: JournalEntryHeader,
643 pub lines: Vec<JournalEntryLine>,
645}
646
647impl JournalEntry {
648 pub fn new(header: JournalEntryHeader) -> Self {
650 Self {
651 header,
652 lines: Vec::new(),
653 }
654 }
655
656 pub fn new_simple(
662 _document_number: String,
663 company_code: String,
664 posting_date: NaiveDate,
665 description: String,
666 ) -> Self {
667 let mut header = JournalEntryHeader::new(company_code, posting_date);
668 header.header_text = Some(description);
669 Self {
670 header,
671 lines: Vec::new(),
672 }
673 }
674
675 pub fn add_line(&mut self, line: JournalEntryLine) {
677 self.lines.push(line);
678 }
679
680 pub fn total_debit(&self) -> Decimal {
682 self.lines.iter().map(|l| l.debit_amount).sum()
683 }
684
685 pub fn total_credit(&self) -> Decimal {
687 self.lines.iter().map(|l| l.credit_amount).sum()
688 }
689
690 pub fn is_balanced(&self) -> bool {
692 self.total_debit() == self.total_credit()
693 }
694
695 pub fn balance_difference(&self) -> Decimal {
697 self.total_debit() - self.total_credit()
698 }
699
700 pub fn line_count(&self) -> usize {
702 self.lines.len()
703 }
704
705 pub fn has_even_line_count(&self) -> bool {
707 self.lines.len().is_multiple_of(2)
708 }
709
710 pub fn debit_credit_counts(&self) -> (usize, usize) {
712 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
713 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
714 (debits, credits)
715 }
716
717 pub fn has_equal_debit_credit_counts(&self) -> bool {
719 let (d, c) = self.debit_credit_counts();
720 d == c
721 }
722
723 pub fn unique_accounts(&self) -> Vec<&str> {
725 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
726 accounts.sort();
727 accounts.dedup();
728 accounts
729 }
730
731 pub fn has_suspense_posting(&self) -> bool {
733 self.lines.iter().any(|l| l.is_suspense)
734 }
735
736 pub fn company_code(&self) -> &str {
740 &self.header.company_code
741 }
742
743 pub fn document_number(&self) -> String {
745 self.header.document_id.to_string()
746 }
747
748 pub fn posting_date(&self) -> NaiveDate {
750 self.header.posting_date
751 }
752
753 pub fn document_date(&self) -> NaiveDate {
755 self.header.document_date
756 }
757
758 pub fn fiscal_year(&self) -> u16 {
760 self.header.fiscal_year
761 }
762
763 pub fn fiscal_period(&self) -> u8 {
765 self.header.fiscal_period
766 }
767
768 pub fn currency(&self) -> &str {
770 &self.header.currency
771 }
772
773 pub fn is_fraud(&self) -> bool {
775 self.header.is_fraud
776 }
777
778 pub fn has_sod_violation(&self) -> bool {
780 self.header.sod_violation
781 }
782
783 pub fn description(&self) -> Option<&str> {
785 self.header.header_text.as_deref()
786 }
787
788 pub fn set_description(&mut self, description: String) {
790 self.header.header_text = Some(description);
791 }
792}
793
794#[cfg(test)]
795#[allow(clippy::unwrap_used)]
796mod tests {
797 use super::*;
798
799 #[test]
800 fn test_balanced_entry() {
801 let header = JournalEntryHeader::new(
802 "1000".to_string(),
803 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
804 );
805 let mut entry = JournalEntry::new(header);
806
807 entry.add_line(JournalEntryLine::debit(
808 entry.header.document_id,
809 1,
810 "100000".to_string(),
811 Decimal::from(1000),
812 ));
813 entry.add_line(JournalEntryLine::credit(
814 entry.header.document_id,
815 2,
816 "200000".to_string(),
817 Decimal::from(1000),
818 ));
819
820 assert!(entry.is_balanced());
821 assert_eq!(entry.line_count(), 2);
822 assert!(entry.has_even_line_count());
823 assert!(entry.has_equal_debit_credit_counts());
824 }
825
826 #[test]
827 fn test_unbalanced_entry() {
828 let header = JournalEntryHeader::new(
829 "1000".to_string(),
830 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
831 );
832 let mut entry = JournalEntry::new(header);
833
834 entry.add_line(JournalEntryLine::debit(
835 entry.header.document_id,
836 1,
837 "100000".to_string(),
838 Decimal::from(1000),
839 ));
840 entry.add_line(JournalEntryLine::credit(
841 entry.header.document_id,
842 2,
843 "200000".to_string(),
844 Decimal::from(500),
845 ));
846
847 assert!(!entry.is_balanced());
848 assert_eq!(entry.balance_difference(), Decimal::from(500));
849 }
850}