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 Treasury,
60 Tax,
62 Intercompany,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub struct DocumentType {
71 pub code: String,
73 pub description: String,
75 pub business_process: BusinessProcess,
77 pub is_reversal: bool,
79}
80
81impl DocumentType {
82 pub fn gl_account() -> Self {
84 Self {
85 code: "SA".to_string(),
86 description: "G/L Account Document".to_string(),
87 business_process: BusinessProcess::R2R,
88 is_reversal: false,
89 }
90 }
91
92 pub fn vendor_invoice() -> Self {
94 Self {
95 code: "KR".to_string(),
96 description: "Vendor Invoice".to_string(),
97 business_process: BusinessProcess::P2P,
98 is_reversal: false,
99 }
100 }
101
102 pub fn customer_invoice() -> Self {
104 Self {
105 code: "DR".to_string(),
106 description: "Customer Invoice".to_string(),
107 business_process: BusinessProcess::O2C,
108 is_reversal: false,
109 }
110 }
111
112 pub fn vendor_payment() -> Self {
114 Self {
115 code: "KZ".to_string(),
116 description: "Vendor Payment".to_string(),
117 business_process: BusinessProcess::P2P,
118 is_reversal: false,
119 }
120 }
121
122 pub fn customer_payment() -> Self {
124 Self {
125 code: "DZ".to_string(),
126 description: "Customer Payment".to_string(),
127 business_process: BusinessProcess::O2C,
128 is_reversal: false,
129 }
130 }
131
132 pub fn asset_posting() -> Self {
134 Self {
135 code: "AA".to_string(),
136 description: "Asset Posting".to_string(),
137 business_process: BusinessProcess::A2R,
138 is_reversal: false,
139 }
140 }
141
142 pub fn payroll() -> Self {
144 Self {
145 code: "PR".to_string(),
146 description: "Payroll Document".to_string(),
147 business_process: BusinessProcess::H2R,
148 is_reversal: false,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct JournalEntryHeader {
159 pub document_id: Uuid,
161
162 pub company_code: String,
164
165 pub fiscal_year: u16,
167
168 pub fiscal_period: u8,
170
171 pub posting_date: NaiveDate,
173
174 pub document_date: NaiveDate,
176
177 pub created_at: DateTime<Utc>,
179
180 pub document_type: String,
182
183 pub currency: String,
185
186 pub exchange_rate: Decimal,
188
189 pub reference: Option<String>,
191
192 pub header_text: Option<String>,
194
195 pub created_by: String,
197
198 pub user_persona: String,
200
201 pub source: TransactionSource,
203
204 pub business_process: Option<BusinessProcess>,
206
207 pub ledger: String,
209
210 pub is_fraud: bool,
212
213 pub fraud_type: Option<FraudType>,
215
216 #[serde(default)]
219 pub is_anomaly: bool,
220
221 #[serde(default)]
223 pub anomaly_id: Option<String>,
224
225 #[serde(default)]
227 pub anomaly_type: Option<String>,
228
229 pub batch_id: Option<Uuid>,
231
232 #[serde(default)]
235 pub control_ids: Vec<String>,
236
237 #[serde(default)]
239 pub sox_relevant: bool,
240
241 #[serde(default)]
243 pub control_status: super::internal_control::ControlStatus,
244
245 #[serde(default)]
247 pub sod_violation: bool,
248
249 #[serde(default)]
251 pub sod_conflict_type: Option<super::sod::SodConflictType>,
252
253 #[serde(default)]
256 pub approval_workflow: Option<ApprovalWorkflow>,
257
258 #[serde(default)]
261 pub ocpm_event_ids: Vec<Uuid>,
262
263 #[serde(default)]
265 pub ocpm_object_ids: Vec<Uuid>,
266
267 #[serde(default)]
269 pub ocpm_case_id: Option<Uuid>,
270}
271
272impl JournalEntryHeader {
273 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
275 Self {
276 document_id: Uuid::now_v7(),
277 company_code,
278 fiscal_year: posting_date.year() as u16,
279 fiscal_period: posting_date.month() as u8,
280 posting_date,
281 document_date: posting_date,
282 created_at: Utc::now(),
283 document_type: "SA".to_string(),
284 currency: "USD".to_string(),
285 exchange_rate: Decimal::ONE,
286 reference: None,
287 header_text: None,
288 created_by: "SYSTEM".to_string(),
289 user_persona: "automated_system".to_string(),
290 source: TransactionSource::Automated,
291 business_process: Some(BusinessProcess::R2R),
292 ledger: "0L".to_string(),
293 is_fraud: false,
294 fraud_type: None,
295 is_anomaly: false,
297 anomaly_id: None,
298 anomaly_type: None,
299 batch_id: None,
300 control_ids: Vec::new(),
302 sox_relevant: false,
303 control_status: super::internal_control::ControlStatus::default(),
304 sod_violation: false,
305 sod_conflict_type: None,
306 approval_workflow: None,
308 ocpm_event_ids: Vec::new(),
310 ocpm_object_ids: Vec::new(),
311 ocpm_case_id: None,
312 }
313 }
314
315 pub fn with_deterministic_id(
320 company_code: String,
321 posting_date: NaiveDate,
322 document_id: Uuid,
323 ) -> Self {
324 Self {
325 document_id,
326 company_code,
327 fiscal_year: posting_date.year() as u16,
328 fiscal_period: posting_date.month() as u8,
329 posting_date,
330 document_date: posting_date,
331 created_at: Utc::now(),
332 document_type: "SA".to_string(),
333 currency: "USD".to_string(),
334 exchange_rate: Decimal::ONE,
335 reference: None,
336 header_text: None,
337 created_by: "SYSTEM".to_string(),
338 user_persona: "automated_system".to_string(),
339 source: TransactionSource::Automated,
340 business_process: Some(BusinessProcess::R2R),
341 ledger: "0L".to_string(),
342 is_fraud: false,
343 fraud_type: None,
344 is_anomaly: false,
346 anomaly_id: None,
347 anomaly_type: None,
348 batch_id: None,
349 control_ids: Vec::new(),
351 sox_relevant: false,
352 control_status: super::internal_control::ControlStatus::default(),
353 sod_violation: false,
354 sod_conflict_type: None,
355 approval_workflow: None,
357 ocpm_event_ids: Vec::new(),
359 ocpm_object_ids: Vec::new(),
360 ocpm_case_id: None,
361 }
362 }
363}
364
365use chrono::Datelike;
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct JournalEntryLine {
373 pub document_id: Uuid,
375
376 pub line_number: u32,
378
379 pub gl_account: String,
381
382 #[serde(default)]
384 pub account_code: String,
385
386 #[serde(default)]
388 pub account_description: Option<String>,
389
390 #[serde(with = "rust_decimal::serde::str")]
392 pub debit_amount: Decimal,
393
394 #[serde(with = "rust_decimal::serde::str")]
396 pub credit_amount: Decimal,
397
398 #[serde(with = "rust_decimal::serde::str")]
400 pub local_amount: Decimal,
401
402 #[serde(default, with = "rust_decimal::serde::str_option")]
404 pub group_amount: Option<Decimal>,
405
406 pub cost_center: Option<String>,
408
409 pub profit_center: Option<String>,
411
412 pub segment: Option<String>,
414
415 pub functional_area: Option<String>,
417
418 pub line_text: Option<String>,
420
421 #[serde(default)]
423 pub text: Option<String>,
424
425 #[serde(default)]
427 pub reference: Option<String>,
428
429 #[serde(default)]
431 pub value_date: Option<NaiveDate>,
432
433 pub tax_code: Option<String>,
435
436 #[serde(default, with = "rust_decimal::serde::str_option")]
438 pub tax_amount: Option<Decimal>,
439
440 pub assignment: Option<String>,
442
443 pub offsetting_account: Option<String>,
445
446 pub is_suspense: bool,
448
449 pub trading_partner: Option<String>,
451
452 #[serde(default, with = "rust_decimal::serde::str_option")]
454 pub quantity: Option<Decimal>,
455
456 pub unit_of_measure: Option<String>,
458
459 #[serde(default)]
461 pub unit: Option<String>,
462
463 #[serde(default)]
465 pub project_code: Option<String>,
466}
467
468impl JournalEntryLine {
469 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
471 Self {
472 document_id,
473 line_number,
474 gl_account: gl_account.clone(),
475 account_code: gl_account,
476 account_description: None,
477 debit_amount: amount,
478 credit_amount: Decimal::ZERO,
479 local_amount: amount,
480 group_amount: None,
481 cost_center: None,
482 profit_center: None,
483 segment: None,
484 functional_area: None,
485 line_text: None,
486 text: None,
487 reference: None,
488 value_date: None,
489 tax_code: None,
490 tax_amount: None,
491 assignment: None,
492 offsetting_account: None,
493 is_suspense: false,
494 trading_partner: None,
495 quantity: None,
496 unit_of_measure: None,
497 unit: None,
498 project_code: None,
499 }
500 }
501
502 pub fn credit(
504 document_id: Uuid,
505 line_number: u32,
506 gl_account: String,
507 amount: Decimal,
508 ) -> Self {
509 Self {
510 document_id,
511 line_number,
512 gl_account: gl_account.clone(),
513 account_code: gl_account,
514 account_description: None,
515 debit_amount: Decimal::ZERO,
516 credit_amount: amount,
517 local_amount: -amount,
518 group_amount: None,
519 cost_center: None,
520 profit_center: None,
521 segment: None,
522 functional_area: None,
523 line_text: None,
524 text: None,
525 reference: None,
526 value_date: None,
527 tax_code: None,
528 tax_amount: None,
529 assignment: None,
530 offsetting_account: None,
531 is_suspense: false,
532 trading_partner: None,
533 quantity: None,
534 unit_of_measure: None,
535 unit: None,
536 project_code: None,
537 }
538 }
539
540 pub fn is_debit(&self) -> bool {
542 self.debit_amount > Decimal::ZERO
543 }
544
545 pub fn is_credit(&self) -> bool {
547 self.credit_amount > Decimal::ZERO
548 }
549
550 pub fn signed_amount(&self) -> Decimal {
552 self.debit_amount - self.credit_amount
553 }
554
555 #[allow(clippy::misnamed_getters)]
559 pub fn account_code(&self) -> &str {
560 &self.gl_account
561 }
562
563 pub fn account_description(&self) -> &str {
565 ""
567 }
568}
569
570impl Default for JournalEntryLine {
571 fn default() -> Self {
572 Self {
573 document_id: Uuid::nil(),
574 line_number: 0,
575 gl_account: String::new(),
576 account_code: String::new(),
577 account_description: None,
578 debit_amount: Decimal::ZERO,
579 credit_amount: Decimal::ZERO,
580 local_amount: Decimal::ZERO,
581 group_amount: None,
582 cost_center: None,
583 profit_center: None,
584 segment: None,
585 functional_area: None,
586 line_text: None,
587 text: None,
588 reference: None,
589 value_date: None,
590 tax_code: None,
591 tax_amount: None,
592 assignment: None,
593 offsetting_account: None,
594 is_suspense: false,
595 trading_partner: None,
596 quantity: None,
597 unit_of_measure: None,
598 unit: None,
599 project_code: None,
600 }
601 }
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct JournalEntry {
610 pub header: JournalEntryHeader,
612 pub lines: Vec<JournalEntryLine>,
614}
615
616impl JournalEntry {
617 pub fn new(header: JournalEntryHeader) -> Self {
619 Self {
620 header,
621 lines: Vec::new(),
622 }
623 }
624
625 pub fn new_simple(
631 _document_number: String,
632 company_code: String,
633 posting_date: NaiveDate,
634 description: String,
635 ) -> Self {
636 let mut header = JournalEntryHeader::new(company_code, posting_date);
637 header.header_text = Some(description);
638 Self {
639 header,
640 lines: Vec::new(),
641 }
642 }
643
644 pub fn add_line(&mut self, line: JournalEntryLine) {
646 self.lines.push(line);
647 }
648
649 pub fn total_debit(&self) -> Decimal {
651 self.lines.iter().map(|l| l.debit_amount).sum()
652 }
653
654 pub fn total_credit(&self) -> Decimal {
656 self.lines.iter().map(|l| l.credit_amount).sum()
657 }
658
659 pub fn is_balanced(&self) -> bool {
661 self.total_debit() == self.total_credit()
662 }
663
664 pub fn balance_difference(&self) -> Decimal {
666 self.total_debit() - self.total_credit()
667 }
668
669 pub fn line_count(&self) -> usize {
671 self.lines.len()
672 }
673
674 pub fn has_even_line_count(&self) -> bool {
676 self.lines.len() % 2 == 0
677 }
678
679 pub fn debit_credit_counts(&self) -> (usize, usize) {
681 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
682 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
683 (debits, credits)
684 }
685
686 pub fn has_equal_debit_credit_counts(&self) -> bool {
688 let (d, c) = self.debit_credit_counts();
689 d == c
690 }
691
692 pub fn unique_accounts(&self) -> Vec<&str> {
694 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
695 accounts.sort();
696 accounts.dedup();
697 accounts
698 }
699
700 pub fn has_suspense_posting(&self) -> bool {
702 self.lines.iter().any(|l| l.is_suspense)
703 }
704
705 pub fn company_code(&self) -> &str {
709 &self.header.company_code
710 }
711
712 pub fn document_number(&self) -> String {
714 self.header.document_id.to_string()
715 }
716
717 pub fn posting_date(&self) -> NaiveDate {
719 self.header.posting_date
720 }
721
722 pub fn document_date(&self) -> NaiveDate {
724 self.header.document_date
725 }
726
727 pub fn fiscal_year(&self) -> u16 {
729 self.header.fiscal_year
730 }
731
732 pub fn fiscal_period(&self) -> u8 {
734 self.header.fiscal_period
735 }
736
737 pub fn currency(&self) -> &str {
739 &self.header.currency
740 }
741
742 pub fn is_fraud(&self) -> bool {
744 self.header.is_fraud
745 }
746
747 pub fn has_sod_violation(&self) -> bool {
749 self.header.sod_violation
750 }
751
752 pub fn description(&self) -> Option<&str> {
754 self.header.header_text.as_deref()
755 }
756
757 pub fn set_description(&mut self, description: String) {
759 self.header.header_text = Some(description);
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn test_balanced_entry() {
769 let header = JournalEntryHeader::new(
770 "1000".to_string(),
771 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
772 );
773 let mut entry = JournalEntry::new(header);
774
775 entry.add_line(JournalEntryLine::debit(
776 entry.header.document_id,
777 1,
778 "100000".to_string(),
779 Decimal::from(1000),
780 ));
781 entry.add_line(JournalEntryLine::credit(
782 entry.header.document_id,
783 2,
784 "200000".to_string(),
785 Decimal::from(1000),
786 ));
787
788 assert!(entry.is_balanced());
789 assert_eq!(entry.line_count(), 2);
790 assert!(entry.has_even_line_count());
791 assert!(entry.has_equal_debit_credit_counts());
792 }
793
794 #[test]
795 fn test_unbalanced_entry() {
796 let header = JournalEntryHeader::new(
797 "1000".to_string(),
798 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
799 );
800 let mut entry = JournalEntry::new(header);
801
802 entry.add_line(JournalEntryLine::debit(
803 entry.header.document_id,
804 1,
805 "100000".to_string(),
806 Decimal::from(1000),
807 ));
808 entry.add_line(JournalEntryLine::credit(
809 entry.header.document_id,
810 2,
811 "200000".to_string(),
812 Decimal::from(500),
813 ));
814
815 assert!(!entry.is_balanced());
816 assert_eq!(entry.balance_difference(), Decimal::from(500));
817 }
818}