1use chrono::{DateTime, NaiveDate, NaiveDateTime, 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 pub fn document_id(&self) -> Option<&str> {
62 match self {
63 Self::PurchaseOrder(id)
64 | Self::VendorInvoice(id)
65 | Self::CustomerInvoice(id)
66 | Self::GoodsReceipt(id)
67 | Self::Delivery(id)
68 | Self::Payment(id)
69 | Self::Receipt(id) => Some(id.as_str()),
70 Self::Manual => None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum TransactionSource {
82 #[default]
84 Manual,
85 Automated,
87 Recurring,
89 Reversal,
91 Adjustment,
93 Statistical,
95}
96
97impl std::fmt::Display for TransactionSource {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 Self::Manual => write!(f, "manual"),
101 Self::Automated => write!(f, "automated"),
102 Self::Recurring => write!(f, "recurring"),
103 Self::Reversal => write!(f, "reversal"),
104 Self::Adjustment => write!(f, "adjustment"),
105 Self::Statistical => write!(f, "statistical"),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
118#[serde(rename_all = "UPPERCASE")]
119pub enum BusinessProcess {
120 O2C,
122 P2P,
124 #[default]
126 R2R,
127 H2R,
129 A2R,
131 S2C,
133 #[serde(rename = "MFG")]
135 Mfg,
136 #[serde(rename = "BANK")]
138 Bank,
139 #[serde(rename = "AUDIT")]
141 Audit,
142 Treasury,
144 Tax,
146 Intercompany,
148 #[serde(rename = "PROJECT")]
150 ProjectAccounting,
151 #[serde(rename = "ESG")]
153 Esg,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
160pub struct DocumentType {
161 pub code: String,
163 pub description: String,
165 pub business_process: BusinessProcess,
167 pub is_reversal: bool,
169}
170
171impl DocumentType {
172 pub fn gl_account() -> Self {
174 Self {
175 code: "SA".to_string(),
176 description: "G/L Account Document".to_string(),
177 business_process: BusinessProcess::R2R,
178 is_reversal: false,
179 }
180 }
181
182 pub fn vendor_invoice() -> Self {
184 Self {
185 code: "KR".to_string(),
186 description: "Vendor Invoice".to_string(),
187 business_process: BusinessProcess::P2P,
188 is_reversal: false,
189 }
190 }
191
192 pub fn customer_invoice() -> Self {
194 Self {
195 code: "DR".to_string(),
196 description: "Customer Invoice".to_string(),
197 business_process: BusinessProcess::O2C,
198 is_reversal: false,
199 }
200 }
201
202 pub fn vendor_payment() -> Self {
204 Self {
205 code: "KZ".to_string(),
206 description: "Vendor Payment".to_string(),
207 business_process: BusinessProcess::P2P,
208 is_reversal: false,
209 }
210 }
211
212 pub fn customer_payment() -> Self {
214 Self {
215 code: "DZ".to_string(),
216 description: "Customer Payment".to_string(),
217 business_process: BusinessProcess::O2C,
218 is_reversal: false,
219 }
220 }
221
222 pub fn asset_posting() -> Self {
224 Self {
225 code: "AA".to_string(),
226 description: "Asset Posting".to_string(),
227 business_process: BusinessProcess::A2R,
228 is_reversal: false,
229 }
230 }
231
232 pub fn payroll() -> Self {
234 Self {
235 code: "PR".to_string(),
236 description: "Payroll Document".to_string(),
237 business_process: BusinessProcess::H2R,
238 is_reversal: false,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct JournalEntryHeader {
249 pub document_id: Uuid,
251
252 pub company_code: String,
254
255 pub fiscal_year: u16,
257
258 pub fiscal_period: u8,
260
261 pub posting_date: NaiveDate,
263
264 pub document_date: NaiveDate,
266
267 #[serde(with = "crate::serde_timestamp::utc")]
269 pub created_at: DateTime<Utc>,
270
271 pub document_type: String,
273
274 pub currency: String,
276
277 #[serde(with = "crate::serde_decimal")]
279 pub exchange_rate: Decimal,
280
281 pub reference: Option<String>,
283
284 pub header_text: Option<String>,
286
287 pub created_by: String,
289
290 pub user_persona: String,
292
293 pub source: TransactionSource,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub sap_source_code: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub approver: Option<String>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub trading_partner: Option<String>,
315
316 pub business_process: Option<BusinessProcess>,
318
319 pub ledger: String,
321
322 pub is_fraud: bool,
324
325 pub fraud_type: Option<FraudType>,
327
328 #[serde(default)]
333 pub is_fraud_propagated: bool,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub fraud_source_document_id: Option<String>,
339
340 #[serde(default)]
343 pub is_anomaly: bool,
344
345 #[serde(default)]
347 pub anomaly_id: Option<String>,
348
349 #[serde(default)]
351 pub anomaly_type: Option<String>,
352
353 pub batch_id: Option<Uuid>,
355
356 #[serde(default)]
360 pub is_manual: bool,
361
362 #[serde(default)]
365 pub is_post_close: bool,
366
367 #[serde(default)]
370 pub source_system: String,
371
372 #[serde(default, with = "crate::serde_timestamp::naive::option")]
376 pub created_date: Option<NaiveDateTime>,
377
378 #[serde(default)]
381 pub control_ids: Vec<String>,
382
383 #[serde(default)]
385 pub sox_relevant: bool,
386
387 #[serde(default)]
389 pub control_status: super::internal_control::ControlStatus,
390
391 #[serde(default)]
393 pub sod_violation: bool,
394
395 #[serde(default)]
397 pub sod_conflict_type: Option<super::sod::SodConflictType>,
398
399 #[serde(default)]
401 pub is_elimination: bool,
402
403 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub ic_pair_id: Option<crate::models::IcPairId>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub ic_partner_entity: Option<String>,
414
415 #[serde(default)]
418 pub approval_workflow: Option<ApprovalWorkflow>,
419
420 #[serde(default)]
423 pub source_document: Option<DocumentRef>,
424 #[serde(default)]
426 pub approved_by: Option<String>,
427 #[serde(default)]
429 pub approval_date: Option<NaiveDate>,
430
431 #[serde(default)]
434 pub ocpm_event_ids: Vec<Uuid>,
435
436 #[serde(default)]
438 pub ocpm_object_ids: Vec<Uuid>,
439
440 #[serde(default)]
442 pub ocpm_case_id: Option<Uuid>,
443}
444
445impl JournalEntryHeader {
446 pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
448 Self {
449 document_id: crate::clock::next_document_id(),
450 company_code,
451 fiscal_year: posting_date.year() as u16,
452 fiscal_period: posting_date.month() as u8,
453 posting_date,
454 document_date: posting_date,
455 created_at: crate::clock::now(),
456 document_type: "SA".to_string(),
457 currency: "USD".to_string(),
458 exchange_rate: Decimal::ONE,
459 reference: None,
460 header_text: None,
461 created_by: "SYSTEM".to_string(),
462 user_persona: "automated_system".to_string(),
463 source: TransactionSource::Automated,
464 sap_source_code: None,
465 approver: None,
466 trading_partner: None,
467 business_process: Some(BusinessProcess::R2R),
468 ledger: "0L".to_string(),
469 is_fraud: false,
470 fraud_type: None,
471 is_fraud_propagated: false,
472 fraud_source_document_id: None,
473 is_anomaly: false,
475 anomaly_id: None,
476 anomaly_type: None,
477 batch_id: None,
478 is_manual: false,
480 is_post_close: false,
481 source_system: String::new(),
482 created_date: None,
483 control_ids: Vec::new(),
485 sox_relevant: false,
486 control_status: super::internal_control::ControlStatus::default(),
487 sod_violation: false,
488 sod_conflict_type: None,
489 is_elimination: false,
491 ic_pair_id: None,
493 ic_partner_entity: None,
494 approval_workflow: None,
496 source_document: None,
498 approved_by: None,
499 approval_date: None,
500 ocpm_event_ids: Vec::new(),
502 ocpm_object_ids: Vec::new(),
503 ocpm_case_id: None,
504 }
505 }
506
507 pub fn with_deterministic_id(
512 company_code: String,
513 posting_date: NaiveDate,
514 document_id: Uuid,
515 ) -> Self {
516 Self {
517 document_id,
518 company_code,
519 fiscal_year: posting_date.year() as u16,
520 fiscal_period: posting_date.month() as u8,
521 posting_date,
522 document_date: posting_date,
523 created_at: crate::clock::now(),
524 document_type: "SA".to_string(),
525 currency: "USD".to_string(),
526 exchange_rate: Decimal::ONE,
527 reference: None,
528 header_text: None,
529 created_by: "SYSTEM".to_string(),
530 user_persona: "automated_system".to_string(),
531 source: TransactionSource::Automated,
532 sap_source_code: None,
533 approver: None,
534 trading_partner: None,
535 business_process: Some(BusinessProcess::R2R),
536 ledger: "0L".to_string(),
537 is_fraud: false,
538 fraud_type: None,
539 is_fraud_propagated: false,
540 fraud_source_document_id: None,
541 is_anomaly: false,
543 anomaly_id: None,
544 anomaly_type: None,
545 batch_id: None,
546 is_manual: false,
548 is_post_close: false,
549 source_system: String::new(),
550 created_date: None,
551 control_ids: Vec::new(),
553 sox_relevant: false,
554 control_status: super::internal_control::ControlStatus::default(),
555 sod_violation: false,
556 sod_conflict_type: None,
557 is_elimination: false,
559 ic_pair_id: None,
561 ic_partner_entity: None,
562 approval_workflow: None,
564 source_document: None,
566 approved_by: None,
567 approval_date: None,
568 ocpm_event_ids: Vec::new(),
570 ocpm_object_ids: Vec::new(),
571 ocpm_case_id: None,
572 }
573 }
574
575 pub fn propagate_fraud_from_documents(
585 &mut self,
586 fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
587 ) -> bool {
588 let doc_id = match self
589 .source_document
590 .as_ref()
591 .and_then(DocumentRef::document_id)
592 {
593 Some(id) => id,
594 None => return false,
595 };
596 if let Some(ft) = fraud_map.get(doc_id) {
597 self.is_fraud = true;
598 self.fraud_type = Some(*ft);
599 self.is_fraud_propagated = true;
600 self.fraud_source_document_id = Some(doc_id.to_string());
601 return true;
602 }
603 false
604 }
605}
606
607use chrono::Datelike;
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct JournalEntryLine {
615 pub document_id: Uuid,
617
618 pub line_number: u32,
620
621 pub gl_account: String,
623
624 #[serde(default)]
626 pub account_code: String,
627
628 #[serde(default)]
630 pub account_description: Option<String>,
631
632 #[serde(with = "crate::serde_decimal")]
634 pub debit_amount: Decimal,
635
636 #[serde(with = "crate::serde_decimal")]
638 pub credit_amount: Decimal,
639
640 #[serde(with = "crate::serde_decimal")]
642 pub local_amount: Decimal,
643
644 #[serde(default, with = "crate::serde_decimal::option")]
646 pub group_amount: Option<Decimal>,
647
648 #[serde(default, with = "crate::serde_decimal::option")]
655 pub transaction_amount: Option<Decimal>,
656
657 pub cost_center: Option<String>,
659
660 pub profit_center: Option<String>,
662
663 #[serde(default)]
667 pub business_unit: Option<String>,
668
669 pub segment: Option<String>,
671
672 pub functional_area: Option<String>,
674
675 pub line_text: Option<String>,
677
678 #[serde(default)]
680 pub text: Option<String>,
681
682 #[serde(default)]
684 pub reference: Option<String>,
685
686 #[serde(default)]
688 pub value_date: Option<NaiveDate>,
689
690 pub tax_code: Option<String>,
692
693 #[serde(default, with = "crate::serde_decimal::option")]
695 pub tax_amount: Option<Decimal>,
696
697 pub assignment: Option<String>,
699
700 pub offsetting_account: Option<String>,
702
703 pub is_suspense: bool,
705
706 pub trading_partner: Option<String>,
708
709 #[serde(default, with = "crate::serde_decimal::option")]
711 pub quantity: Option<Decimal>,
712
713 pub unit_of_measure: Option<String>,
715
716 #[serde(default)]
718 pub unit: Option<String>,
719
720 #[serde(default)]
722 pub project_code: Option<String>,
723
724 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub auxiliary_account_number: Option<String>,
728
729 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub auxiliary_account_label: Option<String>,
733
734 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub lettrage: Option<String>,
738
739 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub lettrage_date: Option<NaiveDate>,
743
744 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub transaction_id: Option<String>,
752
753 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub predecessor_line_id: Option<String>,
768}
769
770impl JournalEntryLine {
771 #[inline]
773 pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
774 Self {
775 document_id,
776 line_number,
777 gl_account: gl_account.clone(),
778 account_code: gl_account,
779 account_description: None,
780 debit_amount: amount,
781 credit_amount: Decimal::ZERO,
782 local_amount: amount,
783 group_amount: None,
784 transaction_amount: None,
785 cost_center: None,
786 profit_center: None,
787 business_unit: None,
788 segment: None,
789 functional_area: None,
790 line_text: None,
791 text: None,
792 reference: None,
793 value_date: None,
794 tax_code: None,
795 tax_amount: None,
796 assignment: None,
797 offsetting_account: None,
798 is_suspense: false,
799 trading_partner: None,
800 quantity: None,
801 unit_of_measure: None,
802 unit: None,
803 project_code: None,
804 auxiliary_account_number: None,
805 auxiliary_account_label: None,
806 lettrage: None,
807 lettrage_date: None,
808 transaction_id: None,
809 predecessor_line_id: None,
810 }
811 }
812
813 #[inline]
815 pub fn credit(
816 document_id: Uuid,
817 line_number: u32,
818 gl_account: String,
819 amount: Decimal,
820 ) -> Self {
821 Self {
822 document_id,
823 line_number,
824 gl_account: gl_account.clone(),
825 account_code: gl_account,
826 account_description: None,
827 debit_amount: Decimal::ZERO,
828 credit_amount: amount,
829 local_amount: -amount,
830 group_amount: None,
831 transaction_amount: None,
832 cost_center: None,
833 profit_center: None,
834 business_unit: None,
835 segment: None,
836 functional_area: None,
837 line_text: None,
838 text: None,
839 reference: None,
840 value_date: None,
841 tax_code: None,
842 tax_amount: None,
843 assignment: None,
844 offsetting_account: None,
845 is_suspense: false,
846 trading_partner: None,
847 quantity: None,
848 unit_of_measure: None,
849 unit: None,
850 project_code: None,
851 auxiliary_account_number: None,
852 auxiliary_account_label: None,
853 lettrage: None,
854 lettrage_date: None,
855 transaction_id: None,
856 predecessor_line_id: None,
857 }
858 }
859
860 #[inline]
862 pub fn is_debit(&self) -> bool {
863 self.debit_amount > Decimal::ZERO
864 }
865
866 #[inline]
868 pub fn is_credit(&self) -> bool {
869 self.credit_amount > Decimal::ZERO
870 }
871
872 #[inline]
874 pub fn signed_amount(&self) -> Decimal {
875 self.debit_amount - self.credit_amount
876 }
877
878 pub fn derive_transaction_id(document_id: Uuid, line_number: u32) -> String {
884 let mut input = Vec::with_capacity(20);
885 input.extend_from_slice(document_id.as_bytes());
886 input.extend_from_slice(&line_number.to_le_bytes());
887 Uuid::new_v5(&Uuid::NAMESPACE_OID, &input).to_string()
888 }
889
890 pub fn ensure_transaction_id(&mut self) {
892 if self.transaction_id.is_none() {
893 self.transaction_id = Some(Self::derive_transaction_id(
894 self.document_id,
895 self.line_number,
896 ));
897 }
898 }
899
900 #[allow(clippy::misnamed_getters)]
904 pub fn account_code(&self) -> &str {
905 &self.gl_account
906 }
907
908 pub fn account_description(&self) -> &str {
910 ""
912 }
913}
914
915impl Default for JournalEntryLine {
916 fn default() -> Self {
917 Self {
918 document_id: Uuid::nil(),
919 line_number: 0,
920 gl_account: String::new(),
921 account_code: String::new(),
922 account_description: None,
923 debit_amount: Decimal::ZERO,
924 credit_amount: Decimal::ZERO,
925 local_amount: Decimal::ZERO,
926 group_amount: None,
927 transaction_amount: None,
928 cost_center: None,
929 profit_center: None,
930 business_unit: None,
931 segment: None,
932 functional_area: None,
933 line_text: None,
934 text: None,
935 reference: None,
936 value_date: None,
937 tax_code: None,
938 tax_amount: None,
939 assignment: None,
940 offsetting_account: None,
941 is_suspense: false,
942 trading_partner: None,
943 quantity: None,
944 unit_of_measure: None,
945 unit: None,
946 project_code: None,
947 auxiliary_account_number: None,
948 auxiliary_account_label: None,
949 lettrage: None,
950 lettrage_date: None,
951 transaction_id: None,
952 predecessor_line_id: None,
953 }
954 }
955}
956
957#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct JournalEntry {
968 pub header: JournalEntryHeader,
970 pub lines: SmallVec<[JournalEntryLine; 4]>,
973}
974
975impl JournalEntry {
976 pub fn new(header: JournalEntryHeader) -> Self {
978 Self {
979 header,
980 lines: SmallVec::new(),
981 }
982 }
983
984 pub fn new_simple(
990 document_number: String,
991 company_code: String,
992 posting_date: NaiveDate,
993 description: String,
994 ) -> Self {
995 let mut header = JournalEntryHeader::new(company_code, posting_date);
996 header.header_text = Some(description);
997 header.reference = Some(document_number);
998 Self {
999 header,
1000 lines: SmallVec::new(),
1001 }
1002 }
1003
1004 #[inline]
1008 pub fn add_line(&mut self, mut line: JournalEntryLine) {
1009 line.document_id = self.header.document_id;
1010 self.lines.push(line);
1011 }
1012
1013 pub fn total_debit(&self) -> Decimal {
1015 self.lines.iter().map(|l| l.debit_amount).sum()
1016 }
1017
1018 pub fn total_credit(&self) -> Decimal {
1020 self.lines.iter().map(|l| l.credit_amount).sum()
1021 }
1022
1023 pub fn is_balanced(&self) -> bool {
1025 self.total_debit() == self.total_credit()
1026 }
1027
1028 pub fn balance_difference(&self) -> Decimal {
1030 self.total_debit() - self.total_credit()
1031 }
1032
1033 pub fn line_count(&self) -> usize {
1035 self.lines.len()
1036 }
1037
1038 pub fn has_even_line_count(&self) -> bool {
1040 self.lines.len().is_multiple_of(2)
1041 }
1042
1043 pub fn debit_credit_counts(&self) -> (usize, usize) {
1045 let debits = self.lines.iter().filter(|l| l.is_debit()).count();
1046 let credits = self.lines.iter().filter(|l| l.is_credit()).count();
1047 (debits, credits)
1048 }
1049
1050 pub fn has_equal_debit_credit_counts(&self) -> bool {
1052 let (d, c) = self.debit_credit_counts();
1053 d == c
1054 }
1055
1056 pub fn unique_accounts(&self) -> Vec<&str> {
1058 let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
1059 accounts.sort();
1060 accounts.dedup();
1061 accounts
1062 }
1063
1064 pub fn has_suspense_posting(&self) -> bool {
1066 self.lines.iter().any(|l| l.is_suspense)
1067 }
1068
1069 pub fn company_code(&self) -> &str {
1073 &self.header.company_code
1074 }
1075
1076 pub fn document_number(&self) -> String {
1078 self.header.document_id.to_string()
1079 }
1080
1081 pub fn posting_date(&self) -> NaiveDate {
1083 self.header.posting_date
1084 }
1085
1086 pub fn document_date(&self) -> NaiveDate {
1088 self.header.document_date
1089 }
1090
1091 pub fn fiscal_year(&self) -> u16 {
1093 self.header.fiscal_year
1094 }
1095
1096 pub fn fiscal_period(&self) -> u8 {
1098 self.header.fiscal_period
1099 }
1100
1101 pub fn currency(&self) -> &str {
1103 &self.header.currency
1104 }
1105
1106 pub fn is_fraud(&self) -> bool {
1108 self.header.is_fraud
1109 }
1110
1111 pub fn has_sod_violation(&self) -> bool {
1113 self.header.sod_violation
1114 }
1115
1116 pub fn description(&self) -> Option<&str> {
1118 self.header.header_text.as_deref()
1119 }
1120
1121 pub fn set_description(&mut self, description: String) {
1123 self.header.header_text = Some(description);
1124 }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 #[test]
1132 fn test_balanced_entry() {
1133 let header = JournalEntryHeader::new(
1134 "1000".to_string(),
1135 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1136 );
1137 let mut entry = JournalEntry::new(header);
1138
1139 entry.add_line(JournalEntryLine::debit(
1140 entry.header.document_id,
1141 1,
1142 "100000".to_string(),
1143 Decimal::from(1000),
1144 ));
1145 entry.add_line(JournalEntryLine::credit(
1146 entry.header.document_id,
1147 2,
1148 "200000".to_string(),
1149 Decimal::from(1000),
1150 ));
1151
1152 assert!(entry.is_balanced());
1153 assert_eq!(entry.line_count(), 2);
1154 assert!(entry.has_even_line_count());
1155 assert!(entry.has_equal_debit_credit_counts());
1156 }
1157
1158 #[test]
1159 fn test_unbalanced_entry() {
1160 let header = JournalEntryHeader::new(
1161 "1000".to_string(),
1162 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1163 );
1164 let mut entry = JournalEntry::new(header);
1165
1166 entry.add_line(JournalEntryLine::debit(
1167 entry.header.document_id,
1168 1,
1169 "100000".to_string(),
1170 Decimal::from(1000),
1171 ));
1172 entry.add_line(JournalEntryLine::credit(
1173 entry.header.document_id,
1174 2,
1175 "200000".to_string(),
1176 Decimal::from(500),
1177 ));
1178
1179 assert!(!entry.is_balanced());
1180 assert_eq!(entry.balance_difference(), Decimal::from(500));
1181 }
1182}