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 pub batch_id: Option<Uuid>,
218
219 #[serde(default)]
222 pub control_ids: Vec<String>,
223
224 #[serde(default)]
226 pub sox_relevant: bool,
227
228 #[serde(default)]
230 pub control_status: super::internal_control::ControlStatus,
231
232 #[serde(default)]
234 pub sod_violation: bool,
235
236 #[serde(default)]
238 pub sod_conflict_type: Option<super::sod::SodConflictType>,
239
240 #[serde(default)]
243 pub approval_workflow: Option<ApprovalWorkflow>,
244
245 #[serde(default)]
248 pub ocpm_event_ids: Vec<Uuid>,
249
250 #[serde(default)]
252 pub ocpm_object_ids: Vec<Uuid>,
253
254 #[serde(default)]
256 pub ocpm_case_id: Option<Uuid>,
257}
258
259impl JournalEntryHeader {
260 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
262 Self {
263 document_id: Uuid::now_v7(),
264 company_code,
265 fiscal_year: posting_date.year() as u16,
266 fiscal_period: posting_date.month() as u8,
267 posting_date,
268 document_date: posting_date,
269 created_at: Utc::now(),
270 document_type: "SA".to_string(),
271 currency: "USD".to_string(),
272 exchange_rate: Decimal::ONE,
273 reference: None,
274 header_text: None,
275 created_by: "SYSTEM".to_string(),
276 user_persona: "automated_system".to_string(),
277 source: TransactionSource::Automated,
278 business_process: Some(BusinessProcess::R2R),
279 ledger: "0L".to_string(),
280 is_fraud: false,
281 fraud_type: None,
282 batch_id: None,
283 control_ids: Vec::new(),
285 sox_relevant: false,
286 control_status: super::internal_control::ControlStatus::default(),
287 sod_violation: false,
288 sod_conflict_type: None,
289 approval_workflow: None,
291 ocpm_event_ids: Vec::new(),
293 ocpm_object_ids: Vec::new(),
294 ocpm_case_id: None,
295 }
296 }
297
298 pub fn with_deterministic_id(
303 company_code: String,
304 posting_date: NaiveDate,
305 document_id: Uuid,
306 ) -> Self {
307 Self {
308 document_id,
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 batch_id: None,
328 control_ids: Vec::new(),
330 sox_relevant: false,
331 control_status: super::internal_control::ControlStatus::default(),
332 sod_violation: false,
333 sod_conflict_type: None,
334 approval_workflow: None,
336 ocpm_event_ids: Vec::new(),
338 ocpm_object_ids: Vec::new(),
339 ocpm_case_id: None,
340 }
341 }
342}
343
344use chrono::Datelike;
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct JournalEntryLine {
352 pub document_id: Uuid,
354
355 pub line_number: u32,
357
358 pub gl_account: String,
360
361 #[serde(default)]
363 pub account_code: String,
364
365 #[serde(default)]
367 pub account_description: Option<String>,
368
369 #[serde(with = "rust_decimal::serde::str")]
371 pub debit_amount: Decimal,
372
373 #[serde(with = "rust_decimal::serde::str")]
375 pub credit_amount: Decimal,
376
377 #[serde(with = "rust_decimal::serde::str")]
379 pub local_amount: Decimal,
380
381 #[serde(default, with = "rust_decimal::serde::str_option")]
383 pub group_amount: Option<Decimal>,
384
385 pub cost_center: Option<String>,
387
388 pub profit_center: Option<String>,
390
391 pub segment: Option<String>,
393
394 pub functional_area: Option<String>,
396
397 pub line_text: Option<String>,
399
400 #[serde(default)]
402 pub text: Option<String>,
403
404 #[serde(default)]
406 pub reference: Option<String>,
407
408 #[serde(default)]
410 pub value_date: Option<NaiveDate>,
411
412 pub tax_code: Option<String>,
414
415 #[serde(default, with = "rust_decimal::serde::str_option")]
417 pub tax_amount: Option<Decimal>,
418
419 pub assignment: Option<String>,
421
422 pub offsetting_account: Option<String>,
424
425 pub is_suspense: bool,
427
428 pub trading_partner: Option<String>,
430
431 #[serde(default, with = "rust_decimal::serde::str_option")]
433 pub quantity: Option<Decimal>,
434
435 pub unit_of_measure: Option<String>,
437
438 #[serde(default)]
440 pub unit: Option<String>,
441
442 #[serde(default)]
444 pub project_code: Option<String>,
445}
446
447impl JournalEntryLine {
448 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
450 Self {
451 document_id,
452 line_number,
453 gl_account: gl_account.clone(),
454 account_code: gl_account,
455 account_description: None,
456 debit_amount: amount,
457 credit_amount: Decimal::ZERO,
458 local_amount: amount,
459 group_amount: None,
460 cost_center: None,
461 profit_center: None,
462 segment: None,
463 functional_area: None,
464 line_text: None,
465 text: None,
466 reference: None,
467 value_date: None,
468 tax_code: None,
469 tax_amount: None,
470 assignment: None,
471 offsetting_account: None,
472 is_suspense: false,
473 trading_partner: None,
474 quantity: None,
475 unit_of_measure: None,
476 unit: None,
477 project_code: None,
478 }
479 }
480
481 pub fn credit(
483 document_id: Uuid,
484 line_number: u32,
485 gl_account: String,
486 amount: Decimal,
487 ) -> 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: Decimal::ZERO,
495 credit_amount: amount,
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 is_debit(&self) -> bool {
521 self.debit_amount > Decimal::ZERO
522 }
523
524 pub fn is_credit(&self) -> bool {
526 self.credit_amount > Decimal::ZERO
527 }
528
529 pub fn signed_amount(&self) -> Decimal {
531 self.debit_amount - self.credit_amount
532 }
533
534 #[allow(clippy::misnamed_getters)]
538 pub fn account_code(&self) -> &str {
539 &self.gl_account
540 }
541
542 pub fn account_description(&self) -> &str {
544 ""
546 }
547}
548
549impl Default for JournalEntryLine {
550 fn default() -> Self {
551 Self {
552 document_id: Uuid::nil(),
553 line_number: 0,
554 gl_account: String::new(),
555 account_code: String::new(),
556 account_description: None,
557 debit_amount: Decimal::ZERO,
558 credit_amount: Decimal::ZERO,
559 local_amount: Decimal::ZERO,
560 group_amount: None,
561 cost_center: None,
562 profit_center: None,
563 segment: None,
564 functional_area: None,
565 line_text: None,
566 text: None,
567 reference: None,
568 value_date: None,
569 tax_code: None,
570 tax_amount: None,
571 assignment: None,
572 offsetting_account: None,
573 is_suspense: false,
574 trading_partner: None,
575 quantity: None,
576 unit_of_measure: None,
577 unit: None,
578 project_code: None,
579 }
580 }
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct JournalEntry {
589 pub header: JournalEntryHeader,
591 pub lines: Vec<JournalEntryLine>,
593}
594
595impl JournalEntry {
596 pub fn new(header: JournalEntryHeader) -> Self {
598 Self {
599 header,
600 lines: Vec::new(),
601 }
602 }
603
604 pub fn new_simple(
610 _document_number: String,
611 company_code: String,
612 posting_date: NaiveDate,
613 description: String,
614 ) -> Self {
615 let mut header = JournalEntryHeader::new(company_code, posting_date);
616 header.header_text = Some(description);
617 Self {
618 header,
619 lines: Vec::new(),
620 }
621 }
622
623 pub fn add_line(&mut self, line: JournalEntryLine) {
625 self.lines.push(line);
626 }
627
628 pub fn total_debit(&self) -> Decimal {
630 self.lines.iter().map(|l| l.debit_amount).sum()
631 }
632
633 pub fn total_credit(&self) -> Decimal {
635 self.lines.iter().map(|l| l.credit_amount).sum()
636 }
637
638 pub fn is_balanced(&self) -> bool {
640 self.total_debit() == self.total_credit()
641 }
642
643 pub fn balance_difference(&self) -> Decimal {
645 self.total_debit() - self.total_credit()
646 }
647
648 pub fn line_count(&self) -> usize {
650 self.lines.len()
651 }
652
653 pub fn has_even_line_count(&self) -> bool {
655 self.lines.len() % 2 == 0
656 }
657
658 pub fn debit_credit_counts(&self) -> (usize, usize) {
660 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
661 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
662 (debits, credits)
663 }
664
665 pub fn has_equal_debit_credit_counts(&self) -> bool {
667 let (d, c) = self.debit_credit_counts();
668 d == c
669 }
670
671 pub fn unique_accounts(&self) -> Vec<&str> {
673 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
674 accounts.sort();
675 accounts.dedup();
676 accounts
677 }
678
679 pub fn has_suspense_posting(&self) -> bool {
681 self.lines.iter().any(|l| l.is_suspense)
682 }
683
684 pub fn company_code(&self) -> &str {
688 &self.header.company_code
689 }
690
691 pub fn document_number(&self) -> String {
693 self.header.document_id.to_string()
694 }
695
696 pub fn posting_date(&self) -> NaiveDate {
698 self.header.posting_date
699 }
700
701 pub fn document_date(&self) -> NaiveDate {
703 self.header.document_date
704 }
705
706 pub fn fiscal_year(&self) -> u16 {
708 self.header.fiscal_year
709 }
710
711 pub fn fiscal_period(&self) -> u8 {
713 self.header.fiscal_period
714 }
715
716 pub fn currency(&self) -> &str {
718 &self.header.currency
719 }
720
721 pub fn is_fraud(&self) -> bool {
723 self.header.is_fraud
724 }
725
726 pub fn has_sod_violation(&self) -> bool {
728 self.header.sod_violation
729 }
730
731 pub fn description(&self) -> Option<&str> {
733 self.header.header_text.as_deref()
734 }
735
736 pub fn set_description(&mut self, description: String) {
738 self.header.header_text = Some(description);
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 #[test]
747 fn test_balanced_entry() {
748 let header = JournalEntryHeader::new(
749 "1000".to_string(),
750 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
751 );
752 let mut entry = JournalEntry::new(header);
753
754 entry.add_line(JournalEntryLine::debit(
755 entry.header.document_id,
756 1,
757 "100000".to_string(),
758 Decimal::from(1000),
759 ));
760 entry.add_line(JournalEntryLine::credit(
761 entry.header.document_id,
762 2,
763 "200000".to_string(),
764 Decimal::from(1000),
765 ));
766
767 assert!(entry.is_balanced());
768 assert_eq!(entry.line_count(), 2);
769 assert!(entry.has_even_line_count());
770 assert!(entry.has_equal_debit_credit_counts());
771 }
772
773 #[test]
774 fn test_unbalanced_entry() {
775 let header = JournalEntryHeader::new(
776 "1000".to_string(),
777 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
778 );
779 let mut entry = JournalEntry::new(header);
780
781 entry.add_line(JournalEntryLine::debit(
782 entry.header.document_id,
783 1,
784 "100000".to_string(),
785 Decimal::from(1000),
786 ));
787 entry.add_line(JournalEntryLine::credit(
788 entry.header.document_id,
789 2,
790 "200000".to_string(),
791 Decimal::from(500),
792 ));
793
794 assert!(!entry.is_balanced());
795 assert_eq!(entry.balance_difference(), Decimal::from(500));
796 }
797}