1use chrono::{DateTime, NaiveDate, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11use uuid::Uuid;
12
13use super::anomaly::FraudType;
14use super::approval::ApprovalWorkflow;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub enum DocumentRef {
22 PurchaseOrder(String),
24 VendorInvoice(String),
26 CustomerInvoice(String),
28 GoodsReceipt(String),
30 Delivery(String),
32 Payment(String),
34 Receipt(String),
36 Manual,
38}
39
40impl DocumentRef {
41 pub fn parse(reference: &str) -> Option<Self> {
45 if reference.starts_with("PO-") || reference.starts_with("PO ") {
46 Some(Self::PurchaseOrder(reference.to_string()))
47 } else if reference.starts_with("INV-") || reference.starts_with("INV ") {
48 Some(Self::VendorInvoice(reference.to_string()))
49 } else if reference.starts_with("SO-") || reference.starts_with("SO ") {
50 Some(Self::CustomerInvoice(reference.to_string()))
51 } else if reference.starts_with("GR-") || reference.starts_with("GR ") {
52 Some(Self::GoodsReceipt(reference.to_string()))
53 } else if reference.starts_with("PAY-") || reference.starts_with("PAY ") {
54 Some(Self::Payment(reference.to_string()))
55 } else {
56 None
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum TransactionSource {
68 #[default]
70 Manual,
71 Automated,
73 Recurring,
75 Reversal,
77 Adjustment,
79 Statistical,
81}
82
83impl std::fmt::Display for TransactionSource {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 Self::Manual => write!(f, "manual"),
87 Self::Automated => write!(f, "automated"),
88 Self::Recurring => write!(f, "recurring"),
89 Self::Reversal => write!(f, "reversal"),
90 Self::Adjustment => write!(f, "adjustment"),
91 Self::Statistical => write!(f, "statistical"),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
104#[serde(rename_all = "UPPERCASE")]
105pub enum BusinessProcess {
106 O2C,
108 P2P,
110 #[default]
112 R2R,
113 H2R,
115 A2R,
117 S2C,
119 #[serde(rename = "MFG")]
121 Mfg,
122 #[serde(rename = "BANK")]
124 Bank,
125 #[serde(rename = "AUDIT")]
127 Audit,
128 Treasury,
130 Tax,
132 Intercompany,
134 #[serde(rename = "PROJECT")]
136 ProjectAccounting,
137 #[serde(rename = "ESG")]
139 Esg,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct DocumentType {
147 pub code: String,
149 pub description: String,
151 pub business_process: BusinessProcess,
153 pub is_reversal: bool,
155}
156
157impl DocumentType {
158 pub fn gl_account() -> Self {
160 Self {
161 code: "SA".to_string(),
162 description: "G/L Account Document".to_string(),
163 business_process: BusinessProcess::R2R,
164 is_reversal: false,
165 }
166 }
167
168 pub fn vendor_invoice() -> Self {
170 Self {
171 code: "KR".to_string(),
172 description: "Vendor Invoice".to_string(),
173 business_process: BusinessProcess::P2P,
174 is_reversal: false,
175 }
176 }
177
178 pub fn customer_invoice() -> Self {
180 Self {
181 code: "DR".to_string(),
182 description: "Customer Invoice".to_string(),
183 business_process: BusinessProcess::O2C,
184 is_reversal: false,
185 }
186 }
187
188 pub fn vendor_payment() -> Self {
190 Self {
191 code: "KZ".to_string(),
192 description: "Vendor Payment".to_string(),
193 business_process: BusinessProcess::P2P,
194 is_reversal: false,
195 }
196 }
197
198 pub fn customer_payment() -> Self {
200 Self {
201 code: "DZ".to_string(),
202 description: "Customer Payment".to_string(),
203 business_process: BusinessProcess::O2C,
204 is_reversal: false,
205 }
206 }
207
208 pub fn asset_posting() -> Self {
210 Self {
211 code: "AA".to_string(),
212 description: "Asset Posting".to_string(),
213 business_process: BusinessProcess::A2R,
214 is_reversal: false,
215 }
216 }
217
218 pub fn payroll() -> Self {
220 Self {
221 code: "PR".to_string(),
222 description: "Payroll Document".to_string(),
223 business_process: BusinessProcess::H2R,
224 is_reversal: false,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct JournalEntryHeader {
235 pub document_id: Uuid,
237
238 pub company_code: String,
240
241 pub fiscal_year: u16,
243
244 pub fiscal_period: u8,
246
247 pub posting_date: NaiveDate,
249
250 pub document_date: NaiveDate,
252
253 pub created_at: DateTime<Utc>,
255
256 pub document_type: String,
258
259 pub currency: String,
261
262 #[serde(with = "rust_decimal::serde::str")]
264 pub exchange_rate: Decimal,
265
266 pub reference: Option<String>,
268
269 pub header_text: Option<String>,
271
272 pub created_by: String,
274
275 pub user_persona: String,
277
278 pub source: TransactionSource,
280
281 pub business_process: Option<BusinessProcess>,
283
284 pub ledger: String,
286
287 pub is_fraud: bool,
289
290 pub fraud_type: Option<FraudType>,
292
293 #[serde(default)]
296 pub is_anomaly: bool,
297
298 #[serde(default)]
300 pub anomaly_id: Option<String>,
301
302 #[serde(default)]
304 pub anomaly_type: Option<String>,
305
306 pub batch_id: Option<Uuid>,
308
309 #[serde(default)]
312 pub control_ids: Vec<String>,
313
314 #[serde(default)]
316 pub sox_relevant: bool,
317
318 #[serde(default)]
320 pub control_status: super::internal_control::ControlStatus,
321
322 #[serde(default)]
324 pub sod_violation: bool,
325
326 #[serde(default)]
328 pub sod_conflict_type: Option<super::sod::SodConflictType>,
329
330 #[serde(default)]
333 pub approval_workflow: Option<ApprovalWorkflow>,
334
335 #[serde(default)]
338 pub source_document: Option<DocumentRef>,
339 #[serde(default)]
341 pub approved_by: Option<String>,
342 #[serde(default)]
344 pub approval_date: Option<NaiveDate>,
345
346 #[serde(default)]
349 pub ocpm_event_ids: Vec<Uuid>,
350
351 #[serde(default)]
353 pub ocpm_object_ids: Vec<Uuid>,
354
355 #[serde(default)]
357 pub ocpm_case_id: Option<Uuid>,
358}
359
360impl JournalEntryHeader {
361 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
363 Self {
364 document_id: Uuid::now_v7(),
365 company_code,
366 fiscal_year: posting_date.year() as u16,
367 fiscal_period: posting_date.month() as u8,
368 posting_date,
369 document_date: posting_date,
370 created_at: Utc::now(),
371 document_type: "SA".to_string(),
372 currency: "USD".to_string(),
373 exchange_rate: Decimal::ONE,
374 reference: None,
375 header_text: None,
376 created_by: "SYSTEM".to_string(),
377 user_persona: "automated_system".to_string(),
378 source: TransactionSource::Automated,
379 business_process: Some(BusinessProcess::R2R),
380 ledger: "0L".to_string(),
381 is_fraud: false,
382 fraud_type: None,
383 is_anomaly: false,
385 anomaly_id: None,
386 anomaly_type: None,
387 batch_id: None,
388 control_ids: Vec::new(),
390 sox_relevant: false,
391 control_status: super::internal_control::ControlStatus::default(),
392 sod_violation: false,
393 sod_conflict_type: None,
394 approval_workflow: None,
396 source_document: None,
398 approved_by: None,
399 approval_date: None,
400 ocpm_event_ids: Vec::new(),
402 ocpm_object_ids: Vec::new(),
403 ocpm_case_id: None,
404 }
405 }
406
407 pub fn with_deterministic_id(
412 company_code: String,
413 posting_date: NaiveDate,
414 document_id: Uuid,
415 ) -> Self {
416 Self {
417 document_id,
418 company_code,
419 fiscal_year: posting_date.year() as u16,
420 fiscal_period: posting_date.month() as u8,
421 posting_date,
422 document_date: posting_date,
423 created_at: Utc::now(),
424 document_type: "SA".to_string(),
425 currency: "USD".to_string(),
426 exchange_rate: Decimal::ONE,
427 reference: None,
428 header_text: None,
429 created_by: "SYSTEM".to_string(),
430 user_persona: "automated_system".to_string(),
431 source: TransactionSource::Automated,
432 business_process: Some(BusinessProcess::R2R),
433 ledger: "0L".to_string(),
434 is_fraud: false,
435 fraud_type: None,
436 is_anomaly: false,
438 anomaly_id: None,
439 anomaly_type: None,
440 batch_id: None,
441 control_ids: Vec::new(),
443 sox_relevant: false,
444 control_status: super::internal_control::ControlStatus::default(),
445 sod_violation: false,
446 sod_conflict_type: None,
447 approval_workflow: None,
449 source_document: None,
451 approved_by: None,
452 approval_date: None,
453 ocpm_event_ids: Vec::new(),
455 ocpm_object_ids: Vec::new(),
456 ocpm_case_id: None,
457 }
458 }
459}
460
461use chrono::Datelike;
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct JournalEntryLine {
469 pub document_id: Uuid,
471
472 pub line_number: u32,
474
475 pub gl_account: String,
477
478 #[serde(default)]
480 pub account_code: String,
481
482 #[serde(default)]
484 pub account_description: Option<String>,
485
486 #[serde(with = "rust_decimal::serde::str")]
488 pub debit_amount: Decimal,
489
490 #[serde(with = "rust_decimal::serde::str")]
492 pub credit_amount: Decimal,
493
494 #[serde(with = "rust_decimal::serde::str")]
496 pub local_amount: Decimal,
497
498 #[serde(default, with = "rust_decimal::serde::str_option")]
500 pub group_amount: Option<Decimal>,
501
502 pub cost_center: Option<String>,
504
505 pub profit_center: Option<String>,
507
508 pub segment: Option<String>,
510
511 pub functional_area: Option<String>,
513
514 pub line_text: Option<String>,
516
517 #[serde(default)]
519 pub text: Option<String>,
520
521 #[serde(default)]
523 pub reference: Option<String>,
524
525 #[serde(default)]
527 pub value_date: Option<NaiveDate>,
528
529 pub tax_code: Option<String>,
531
532 #[serde(default, with = "rust_decimal::serde::str_option")]
534 pub tax_amount: Option<Decimal>,
535
536 pub assignment: Option<String>,
538
539 pub offsetting_account: Option<String>,
541
542 pub is_suspense: bool,
544
545 pub trading_partner: Option<String>,
547
548 #[serde(default, with = "rust_decimal::serde::str_option")]
550 pub quantity: Option<Decimal>,
551
552 pub unit_of_measure: Option<String>,
554
555 #[serde(default)]
557 pub unit: Option<String>,
558
559 #[serde(default)]
561 pub project_code: Option<String>,
562
563 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub auxiliary_account_number: Option<String>,
567
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub auxiliary_account_label: Option<String>,
572
573 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub lettrage: Option<String>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub lettrage_date: Option<NaiveDate>,
582}
583
584impl JournalEntryLine {
585 #[inline]
587 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
588 Self {
589 document_id,
590 line_number,
591 gl_account: gl_account.clone(),
592 account_code: gl_account,
593 account_description: None,
594 debit_amount: amount,
595 credit_amount: Decimal::ZERO,
596 local_amount: amount,
597 group_amount: None,
598 cost_center: None,
599 profit_center: None,
600 segment: None,
601 functional_area: None,
602 line_text: None,
603 text: None,
604 reference: None,
605 value_date: None,
606 tax_code: None,
607 tax_amount: None,
608 assignment: None,
609 offsetting_account: None,
610 is_suspense: false,
611 trading_partner: None,
612 quantity: None,
613 unit_of_measure: None,
614 unit: None,
615 project_code: None,
616 auxiliary_account_number: None,
617 auxiliary_account_label: None,
618 lettrage: None,
619 lettrage_date: None,
620 }
621 }
622
623 #[inline]
625 pub fn credit(
626 document_id: Uuid,
627 line_number: u32,
628 gl_account: String,
629 amount: Decimal,
630 ) -> Self {
631 Self {
632 document_id,
633 line_number,
634 gl_account: gl_account.clone(),
635 account_code: gl_account,
636 account_description: None,
637 debit_amount: Decimal::ZERO,
638 credit_amount: amount,
639 local_amount: -amount,
640 group_amount: None,
641 cost_center: None,
642 profit_center: None,
643 segment: None,
644 functional_area: None,
645 line_text: None,
646 text: None,
647 reference: None,
648 value_date: None,
649 tax_code: None,
650 tax_amount: None,
651 assignment: None,
652 offsetting_account: None,
653 is_suspense: false,
654 trading_partner: None,
655 quantity: None,
656 unit_of_measure: None,
657 unit: None,
658 project_code: None,
659 auxiliary_account_number: None,
660 auxiliary_account_label: None,
661 lettrage: None,
662 lettrage_date: None,
663 }
664 }
665
666 #[inline]
668 pub fn is_debit(&self) -> bool {
669 self.debit_amount > Decimal::ZERO
670 }
671
672 #[inline]
674 pub fn is_credit(&self) -> bool {
675 self.credit_amount > Decimal::ZERO
676 }
677
678 #[inline]
680 pub fn signed_amount(&self) -> Decimal {
681 self.debit_amount - self.credit_amount
682 }
683
684 #[allow(clippy::misnamed_getters)]
688 pub fn account_code(&self) -> &str {
689 &self.gl_account
690 }
691
692 pub fn account_description(&self) -> &str {
694 ""
696 }
697}
698
699impl Default for JournalEntryLine {
700 fn default() -> Self {
701 Self {
702 document_id: Uuid::nil(),
703 line_number: 0,
704 gl_account: String::new(),
705 account_code: String::new(),
706 account_description: None,
707 debit_amount: Decimal::ZERO,
708 credit_amount: Decimal::ZERO,
709 local_amount: Decimal::ZERO,
710 group_amount: None,
711 cost_center: None,
712 profit_center: None,
713 segment: None,
714 functional_area: None,
715 line_text: None,
716 text: None,
717 reference: None,
718 value_date: None,
719 tax_code: None,
720 tax_amount: None,
721 assignment: None,
722 offsetting_account: None,
723 is_suspense: false,
724 trading_partner: None,
725 quantity: None,
726 unit_of_measure: None,
727 unit: None,
728 project_code: None,
729 auxiliary_account_number: None,
730 auxiliary_account_label: None,
731 lettrage: None,
732 lettrage_date: None,
733 }
734 }
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct JournalEntry {
748 pub header: JournalEntryHeader,
750 pub lines: SmallVec<[JournalEntryLine; 4]>,
753}
754
755impl JournalEntry {
756 pub fn new(header: JournalEntryHeader) -> Self {
758 Self {
759 header,
760 lines: SmallVec::new(),
761 }
762 }
763
764 pub fn new_simple(
770 _document_number: String,
771 company_code: String,
772 posting_date: NaiveDate,
773 description: String,
774 ) -> Self {
775 let mut header = JournalEntryHeader::new(company_code, posting_date);
776 header.header_text = Some(description);
777 Self {
778 header,
779 lines: SmallVec::new(),
780 }
781 }
782
783 #[inline]
785 pub fn add_line(&mut self, line: JournalEntryLine) {
786 self.lines.push(line);
787 }
788
789 pub fn total_debit(&self) -> Decimal {
791 self.lines.iter().map(|l| l.debit_amount).sum()
792 }
793
794 pub fn total_credit(&self) -> Decimal {
796 self.lines.iter().map(|l| l.credit_amount).sum()
797 }
798
799 pub fn is_balanced(&self) -> bool {
801 self.total_debit() == self.total_credit()
802 }
803
804 pub fn balance_difference(&self) -> Decimal {
806 self.total_debit() - self.total_credit()
807 }
808
809 pub fn line_count(&self) -> usize {
811 self.lines.len()
812 }
813
814 pub fn has_even_line_count(&self) -> bool {
816 self.lines.len().is_multiple_of(2)
817 }
818
819 pub fn debit_credit_counts(&self) -> (usize, usize) {
821 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
822 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
823 (debits, credits)
824 }
825
826 pub fn has_equal_debit_credit_counts(&self) -> bool {
828 let (d, c) = self.debit_credit_counts();
829 d == c
830 }
831
832 pub fn unique_accounts(&self) -> Vec<&str> {
834 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
835 accounts.sort();
836 accounts.dedup();
837 accounts
838 }
839
840 pub fn has_suspense_posting(&self) -> bool {
842 self.lines.iter().any(|l| l.is_suspense)
843 }
844
845 pub fn company_code(&self) -> &str {
849 &self.header.company_code
850 }
851
852 pub fn document_number(&self) -> String {
854 self.header.document_id.to_string()
855 }
856
857 pub fn posting_date(&self) -> NaiveDate {
859 self.header.posting_date
860 }
861
862 pub fn document_date(&self) -> NaiveDate {
864 self.header.document_date
865 }
866
867 pub fn fiscal_year(&self) -> u16 {
869 self.header.fiscal_year
870 }
871
872 pub fn fiscal_period(&self) -> u8 {
874 self.header.fiscal_period
875 }
876
877 pub fn currency(&self) -> &str {
879 &self.header.currency
880 }
881
882 pub fn is_fraud(&self) -> bool {
884 self.header.is_fraud
885 }
886
887 pub fn has_sod_violation(&self) -> bool {
889 self.header.sod_violation
890 }
891
892 pub fn description(&self) -> Option<&str> {
894 self.header.header_text.as_deref()
895 }
896
897 pub fn set_description(&mut self, description: String) {
899 self.header.header_text = Some(description);
900 }
901}
902
903#[cfg(test)]
904#[allow(clippy::unwrap_used)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn test_balanced_entry() {
910 let header = JournalEntryHeader::new(
911 "1000".to_string(),
912 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
913 );
914 let mut entry = JournalEntry::new(header);
915
916 entry.add_line(JournalEntryLine::debit(
917 entry.header.document_id,
918 1,
919 "100000".to_string(),
920 Decimal::from(1000),
921 ));
922 entry.add_line(JournalEntryLine::credit(
923 entry.header.document_id,
924 2,
925 "200000".to_string(),
926 Decimal::from(1000),
927 ));
928
929 assert!(entry.is_balanced());
930 assert_eq!(entry.line_count(), 2);
931 assert!(entry.has_even_line_count());
932 assert!(entry.has_equal_debit_credit_counts());
933 }
934
935 #[test]
936 fn test_unbalanced_entry() {
937 let header = JournalEntryHeader::new(
938 "1000".to_string(),
939 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
940 );
941 let mut entry = JournalEntry::new(header);
942
943 entry.add_line(JournalEntryLine::debit(
944 entry.header.document_id,
945 1,
946 "100000".to_string(),
947 Decimal::from(1000),
948 ));
949 entry.add_line(JournalEntryLine::credit(
950 entry.header.document_id,
951 2,
952 "200000".to_string(),
953 Decimal::from(500),
954 ));
955
956 assert!(!entry.is_balanced());
957 assert_eq!(entry.balance_difference(), Decimal::from(500));
958 }
959}