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 #[serde(rename = "PROJECT")]
77 ProjectAccounting,
78 #[serde(rename = "ESG")]
80 Esg,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub struct DocumentType {
88 pub code: String,
90 pub description: String,
92 pub business_process: BusinessProcess,
94 pub is_reversal: bool,
96}
97
98impl DocumentType {
99 pub fn gl_account() -> Self {
101 Self {
102 code: "SA".to_string(),
103 description: "G/L Account Document".to_string(),
104 business_process: BusinessProcess::R2R,
105 is_reversal: false,
106 }
107 }
108
109 pub fn vendor_invoice() -> Self {
111 Self {
112 code: "KR".to_string(),
113 description: "Vendor Invoice".to_string(),
114 business_process: BusinessProcess::P2P,
115 is_reversal: false,
116 }
117 }
118
119 pub fn customer_invoice() -> Self {
121 Self {
122 code: "DR".to_string(),
123 description: "Customer Invoice".to_string(),
124 business_process: BusinessProcess::O2C,
125 is_reversal: false,
126 }
127 }
128
129 pub fn vendor_payment() -> Self {
131 Self {
132 code: "KZ".to_string(),
133 description: "Vendor Payment".to_string(),
134 business_process: BusinessProcess::P2P,
135 is_reversal: false,
136 }
137 }
138
139 pub fn customer_payment() -> Self {
141 Self {
142 code: "DZ".to_string(),
143 description: "Customer Payment".to_string(),
144 business_process: BusinessProcess::O2C,
145 is_reversal: false,
146 }
147 }
148
149 pub fn asset_posting() -> Self {
151 Self {
152 code: "AA".to_string(),
153 description: "Asset Posting".to_string(),
154 business_process: BusinessProcess::A2R,
155 is_reversal: false,
156 }
157 }
158
159 pub fn payroll() -> Self {
161 Self {
162 code: "PR".to_string(),
163 description: "Payroll Document".to_string(),
164 business_process: BusinessProcess::H2R,
165 is_reversal: false,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct JournalEntryHeader {
176 pub document_id: Uuid,
178
179 pub company_code: String,
181
182 pub fiscal_year: u16,
184
185 pub fiscal_period: u8,
187
188 pub posting_date: NaiveDate,
190
191 pub document_date: NaiveDate,
193
194 pub created_at: DateTime<Utc>,
196
197 pub document_type: String,
199
200 pub currency: String,
202
203 pub exchange_rate: Decimal,
205
206 pub reference: Option<String>,
208
209 pub header_text: Option<String>,
211
212 pub created_by: String,
214
215 pub user_persona: String,
217
218 pub source: TransactionSource,
220
221 pub business_process: Option<BusinessProcess>,
223
224 pub ledger: String,
226
227 pub is_fraud: bool,
229
230 pub fraud_type: Option<FraudType>,
232
233 #[serde(default)]
236 pub is_anomaly: bool,
237
238 #[serde(default)]
240 pub anomaly_id: Option<String>,
241
242 #[serde(default)]
244 pub anomaly_type: Option<String>,
245
246 pub batch_id: Option<Uuid>,
248
249 #[serde(default)]
252 pub control_ids: Vec<String>,
253
254 #[serde(default)]
256 pub sox_relevant: bool,
257
258 #[serde(default)]
260 pub control_status: super::internal_control::ControlStatus,
261
262 #[serde(default)]
264 pub sod_violation: bool,
265
266 #[serde(default)]
268 pub sod_conflict_type: Option<super::sod::SodConflictType>,
269
270 #[serde(default)]
273 pub approval_workflow: Option<ApprovalWorkflow>,
274
275 #[serde(default)]
278 pub ocpm_event_ids: Vec<Uuid>,
279
280 #[serde(default)]
282 pub ocpm_object_ids: Vec<Uuid>,
283
284 #[serde(default)]
286 pub ocpm_case_id: Option<Uuid>,
287}
288
289impl JournalEntryHeader {
290 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
292 Self {
293 document_id: Uuid::now_v7(),
294 company_code,
295 fiscal_year: posting_date.year() as u16,
296 fiscal_period: posting_date.month() as u8,
297 posting_date,
298 document_date: posting_date,
299 created_at: Utc::now(),
300 document_type: "SA".to_string(),
301 currency: "USD".to_string(),
302 exchange_rate: Decimal::ONE,
303 reference: None,
304 header_text: None,
305 created_by: "SYSTEM".to_string(),
306 user_persona: "automated_system".to_string(),
307 source: TransactionSource::Automated,
308 business_process: Some(BusinessProcess::R2R),
309 ledger: "0L".to_string(),
310 is_fraud: false,
311 fraud_type: None,
312 is_anomaly: false,
314 anomaly_id: None,
315 anomaly_type: None,
316 batch_id: None,
317 control_ids: Vec::new(),
319 sox_relevant: false,
320 control_status: super::internal_control::ControlStatus::default(),
321 sod_violation: false,
322 sod_conflict_type: None,
323 approval_workflow: None,
325 ocpm_event_ids: Vec::new(),
327 ocpm_object_ids: Vec::new(),
328 ocpm_case_id: None,
329 }
330 }
331
332 pub fn with_deterministic_id(
337 company_code: String,
338 posting_date: NaiveDate,
339 document_id: Uuid,
340 ) -> Self {
341 Self {
342 document_id,
343 company_code,
344 fiscal_year: posting_date.year() as u16,
345 fiscal_period: posting_date.month() as u8,
346 posting_date,
347 document_date: posting_date,
348 created_at: Utc::now(),
349 document_type: "SA".to_string(),
350 currency: "USD".to_string(),
351 exchange_rate: Decimal::ONE,
352 reference: None,
353 header_text: None,
354 created_by: "SYSTEM".to_string(),
355 user_persona: "automated_system".to_string(),
356 source: TransactionSource::Automated,
357 business_process: Some(BusinessProcess::R2R),
358 ledger: "0L".to_string(),
359 is_fraud: false,
360 fraud_type: None,
361 is_anomaly: false,
363 anomaly_id: None,
364 anomaly_type: None,
365 batch_id: None,
366 control_ids: Vec::new(),
368 sox_relevant: false,
369 control_status: super::internal_control::ControlStatus::default(),
370 sod_violation: false,
371 sod_conflict_type: None,
372 approval_workflow: None,
374 ocpm_event_ids: Vec::new(),
376 ocpm_object_ids: Vec::new(),
377 ocpm_case_id: None,
378 }
379 }
380}
381
382use chrono::Datelike;
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct JournalEntryLine {
390 pub document_id: Uuid,
392
393 pub line_number: u32,
395
396 pub gl_account: String,
398
399 #[serde(default)]
401 pub account_code: String,
402
403 #[serde(default)]
405 pub account_description: Option<String>,
406
407 #[serde(with = "rust_decimal::serde::str")]
409 pub debit_amount: Decimal,
410
411 #[serde(with = "rust_decimal::serde::str")]
413 pub credit_amount: Decimal,
414
415 #[serde(with = "rust_decimal::serde::str")]
417 pub local_amount: Decimal,
418
419 #[serde(default, with = "rust_decimal::serde::str_option")]
421 pub group_amount: Option<Decimal>,
422
423 pub cost_center: Option<String>,
425
426 pub profit_center: Option<String>,
428
429 pub segment: Option<String>,
431
432 pub functional_area: Option<String>,
434
435 pub line_text: Option<String>,
437
438 #[serde(default)]
440 pub text: Option<String>,
441
442 #[serde(default)]
444 pub reference: Option<String>,
445
446 #[serde(default)]
448 pub value_date: Option<NaiveDate>,
449
450 pub tax_code: Option<String>,
452
453 #[serde(default, with = "rust_decimal::serde::str_option")]
455 pub tax_amount: Option<Decimal>,
456
457 pub assignment: Option<String>,
459
460 pub offsetting_account: Option<String>,
462
463 pub is_suspense: bool,
465
466 pub trading_partner: Option<String>,
468
469 #[serde(default, with = "rust_decimal::serde::str_option")]
471 pub quantity: Option<Decimal>,
472
473 pub unit_of_measure: Option<String>,
475
476 #[serde(default)]
478 pub unit: Option<String>,
479
480 #[serde(default)]
482 pub project_code: Option<String>,
483}
484
485impl JournalEntryLine {
486 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
488 Self {
489 document_id,
490 line_number,
491 gl_account: gl_account.clone(),
492 account_code: gl_account,
493 account_description: None,
494 debit_amount: amount,
495 credit_amount: Decimal::ZERO,
496 local_amount: amount,
497 group_amount: None,
498 cost_center: None,
499 profit_center: None,
500 segment: None,
501 functional_area: None,
502 line_text: None,
503 text: None,
504 reference: None,
505 value_date: None,
506 tax_code: None,
507 tax_amount: None,
508 assignment: None,
509 offsetting_account: None,
510 is_suspense: false,
511 trading_partner: None,
512 quantity: None,
513 unit_of_measure: None,
514 unit: None,
515 project_code: None,
516 }
517 }
518
519 pub fn credit(
521 document_id: Uuid,
522 line_number: u32,
523 gl_account: String,
524 amount: Decimal,
525 ) -> Self {
526 Self {
527 document_id,
528 line_number,
529 gl_account: gl_account.clone(),
530 account_code: gl_account,
531 account_description: None,
532 debit_amount: Decimal::ZERO,
533 credit_amount: amount,
534 local_amount: -amount,
535 group_amount: None,
536 cost_center: None,
537 profit_center: None,
538 segment: None,
539 functional_area: None,
540 line_text: None,
541 text: None,
542 reference: None,
543 value_date: None,
544 tax_code: None,
545 tax_amount: None,
546 assignment: None,
547 offsetting_account: None,
548 is_suspense: false,
549 trading_partner: None,
550 quantity: None,
551 unit_of_measure: None,
552 unit: None,
553 project_code: None,
554 }
555 }
556
557 pub fn is_debit(&self) -> bool {
559 self.debit_amount > Decimal::ZERO
560 }
561
562 pub fn is_credit(&self) -> bool {
564 self.credit_amount > Decimal::ZERO
565 }
566
567 pub fn signed_amount(&self) -> Decimal {
569 self.debit_amount - self.credit_amount
570 }
571
572 #[allow(clippy::misnamed_getters)]
576 pub fn account_code(&self) -> &str {
577 &self.gl_account
578 }
579
580 pub fn account_description(&self) -> &str {
582 ""
584 }
585}
586
587impl Default for JournalEntryLine {
588 fn default() -> Self {
589 Self {
590 document_id: Uuid::nil(),
591 line_number: 0,
592 gl_account: String::new(),
593 account_code: String::new(),
594 account_description: None,
595 debit_amount: Decimal::ZERO,
596 credit_amount: Decimal::ZERO,
597 local_amount: Decimal::ZERO,
598 group_amount: None,
599 cost_center: None,
600 profit_center: None,
601 segment: None,
602 functional_area: None,
603 line_text: None,
604 text: None,
605 reference: None,
606 value_date: None,
607 tax_code: None,
608 tax_amount: None,
609 assignment: None,
610 offsetting_account: None,
611 is_suspense: false,
612 trading_partner: None,
613 quantity: None,
614 unit_of_measure: None,
615 unit: None,
616 project_code: None,
617 }
618 }
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct JournalEntry {
627 pub header: JournalEntryHeader,
629 pub lines: Vec<JournalEntryLine>,
631}
632
633impl JournalEntry {
634 pub fn new(header: JournalEntryHeader) -> Self {
636 Self {
637 header,
638 lines: Vec::new(),
639 }
640 }
641
642 pub fn new_simple(
648 _document_number: String,
649 company_code: String,
650 posting_date: NaiveDate,
651 description: String,
652 ) -> Self {
653 let mut header = JournalEntryHeader::new(company_code, posting_date);
654 header.header_text = Some(description);
655 Self {
656 header,
657 lines: Vec::new(),
658 }
659 }
660
661 pub fn add_line(&mut self, line: JournalEntryLine) {
663 self.lines.push(line);
664 }
665
666 pub fn total_debit(&self) -> Decimal {
668 self.lines.iter().map(|l| l.debit_amount).sum()
669 }
670
671 pub fn total_credit(&self) -> Decimal {
673 self.lines.iter().map(|l| l.credit_amount).sum()
674 }
675
676 pub fn is_balanced(&self) -> bool {
678 self.total_debit() == self.total_credit()
679 }
680
681 pub fn balance_difference(&self) -> Decimal {
683 self.total_debit() - self.total_credit()
684 }
685
686 pub fn line_count(&self) -> usize {
688 self.lines.len()
689 }
690
691 pub fn has_even_line_count(&self) -> bool {
693 self.lines.len().is_multiple_of(2)
694 }
695
696 pub fn debit_credit_counts(&self) -> (usize, usize) {
698 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
699 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
700 (debits, credits)
701 }
702
703 pub fn has_equal_debit_credit_counts(&self) -> bool {
705 let (d, c) = self.debit_credit_counts();
706 d == c
707 }
708
709 pub fn unique_accounts(&self) -> Vec<&str> {
711 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
712 accounts.sort();
713 accounts.dedup();
714 accounts
715 }
716
717 pub fn has_suspense_posting(&self) -> bool {
719 self.lines.iter().any(|l| l.is_suspense)
720 }
721
722 pub fn company_code(&self) -> &str {
726 &self.header.company_code
727 }
728
729 pub fn document_number(&self) -> String {
731 self.header.document_id.to_string()
732 }
733
734 pub fn posting_date(&self) -> NaiveDate {
736 self.header.posting_date
737 }
738
739 pub fn document_date(&self) -> NaiveDate {
741 self.header.document_date
742 }
743
744 pub fn fiscal_year(&self) -> u16 {
746 self.header.fiscal_year
747 }
748
749 pub fn fiscal_period(&self) -> u8 {
751 self.header.fiscal_period
752 }
753
754 pub fn currency(&self) -> &str {
756 &self.header.currency
757 }
758
759 pub fn is_fraud(&self) -> bool {
761 self.header.is_fraud
762 }
763
764 pub fn has_sod_violation(&self) -> bool {
766 self.header.sod_violation
767 }
768
769 pub fn description(&self) -> Option<&str> {
771 self.header.header_text.as_deref()
772 }
773
774 pub fn set_description(&mut self, description: String) {
776 self.header.header_text = Some(description);
777 }
778}
779
780#[cfg(test)]
781#[allow(clippy::unwrap_used)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn test_balanced_entry() {
787 let header = JournalEntryHeader::new(
788 "1000".to_string(),
789 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
790 );
791 let mut entry = JournalEntry::new(header);
792
793 entry.add_line(JournalEntryLine::debit(
794 entry.header.document_id,
795 1,
796 "100000".to_string(),
797 Decimal::from(1000),
798 ));
799 entry.add_line(JournalEntryLine::credit(
800 entry.header.document_id,
801 2,
802 "200000".to_string(),
803 Decimal::from(1000),
804 ));
805
806 assert!(entry.is_balanced());
807 assert_eq!(entry.line_count(), 2);
808 assert!(entry.has_even_line_count());
809 assert!(entry.has_equal_debit_credit_counts());
810 }
811
812 #[test]
813 fn test_unbalanced_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(500),
831 ));
832
833 assert!(!entry.is_balanced());
834 assert_eq!(entry.balance_difference(), Decimal::from(500));
835 }
836}