1use chrono::{DateTime, Utc};
16use rust_decimal::Decimal;
17use rust_decimal_macros::dec;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct JournalEntry {
34 pub id: Uuid,
35
36 pub acp_id: Uuid,
48
49 pub organization_id: Uuid,
51 pub building_id: Option<Uuid>,
53 pub entry_date: DateTime<Utc>,
55 pub description: Option<String>,
57 pub document_ref: Option<String>,
59 pub journal_type: Option<String>,
62 pub expense_id: Option<Uuid>,
64 pub contribution_id: Option<Uuid>,
66 pub lines: Vec<JournalEntryLine>,
68 pub created_at: DateTime<Utc>,
69 pub updated_at: DateTime<Utc>,
70 pub created_by: Option<Uuid>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct JournalEntryLine {
78 pub id: Uuid,
79 pub journal_entry_id: Uuid,
80 pub organization_id: Uuid,
81 pub account_code: String,
83 pub debit: Decimal,
85 pub credit: Decimal,
87 pub description: Option<String>,
89 pub created_at: DateTime<Utc>,
90}
91
92const BALANCE_TOLERANCE: Decimal = dec!(0.011);
94
95#[derive(Debug, Clone, PartialEq)]
103pub enum JournalEntryError {
104 NoLines,
106 Unbalanced {
108 debits: Decimal,
109 credits: Decimal,
110 difference: Decimal,
111 tolerance: Decimal,
112 },
113 LineHasBothDebitAndCredit,
115 LineHasNeitherDebitNorCredit,
117 NegativeAmount,
119 MissingAccountCode,
121 InvalidJournalType(String),
123 NonPositiveDebit,
125 NonPositiveCredit,
127 CrossOrgLine,
130}
131
132impl std::fmt::Display for JournalEntryError {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 Self::NoLines => write!(f, "Journal entry must have at least one line"),
136 Self::Unbalanced {
137 debits,
138 credits,
139 difference,
140 tolerance,
141 } => write!(
142 f,
143 "Journal entry is unbalanced: debits={}€, credits={}€, difference={}€ (tolerance: {}€)",
144 debits, credits, difference, tolerance
145 ),
146 Self::LineHasBothDebitAndCredit => {
147 write!(f, "Line cannot have both debit and credit")
148 }
149 Self::LineHasNeitherDebitNorCredit => {
150 write!(f, "Line must have either debit or credit")
151 }
152 Self::NegativeAmount => {
153 write!(f, "Debit and credit amounts must be non-negative")
154 }
155 Self::MissingAccountCode => write!(f, "Account code is required"),
156 Self::InvalidJournalType(jtype) => write!(
157 f,
158 "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
159 jtype
160 ),
161 Self::NonPositiveDebit => write!(f, "Debit amount must be positive"),
162 Self::NonPositiveCredit => write!(f, "Credit amount must be positive"),
163 Self::CrossOrgLine => write!(
164 f,
165 "Journal entry line belongs to a different organization than the entry (cross-org isolation)"
166 ),
167 }
168 }
169}
170
171impl std::error::Error for JournalEntryError {}
172
173impl From<JournalEntryError> for String {
177 fn from(e: JournalEntryError) -> String {
178 e.to_string()
179 }
180}
181
182impl JournalEntry {
183 #[allow(clippy::too_many_arguments)]
195 pub fn new(
196 acp_id: Uuid,
197 organization_id: Uuid,
198 building_id: Option<Uuid>,
199 entry_date: DateTime<Utc>,
200 description: Option<String>,
201 document_ref: Option<String>,
202 journal_type: Option<String>,
203 expense_id: Option<Uuid>,
204 contribution_id: Option<Uuid>,
205 lines: Vec<JournalEntryLine>,
206 created_by: Option<Uuid>,
207 ) -> Result<Self, JournalEntryError> {
208 Self::validate_lines_balance(&lines)?;
210
211 for line in &lines {
215 Self::validate_line(line)?;
216 if line.organization_id != organization_id {
217 return Err(JournalEntryError::CrossOrgLine);
218 }
219 }
220
221 if let Some(ref jtype) = journal_type {
223 if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
224 return Err(JournalEntryError::InvalidJournalType(jtype.clone()));
225 }
226 }
227
228 let now = Utc::now();
229 Ok(Self {
230 id: Uuid::new_v4(),
231 acp_id,
232 organization_id,
233 building_id,
234 entry_date,
235 description,
236 document_ref,
237 journal_type,
238 expense_id,
239 contribution_id,
240 lines,
241 created_at: now,
242 updated_at: now,
243 created_by,
244 })
245 }
246
247 fn validate_lines_balance(lines: &[JournalEntryLine]) -> Result<(), JournalEntryError> {
249 if lines.is_empty() {
250 return Err(JournalEntryError::NoLines);
251 }
252
253 let total_debits: Decimal = lines.iter().map(|l| l.debit).sum();
254 let total_credits: Decimal = lines.iter().map(|l| l.credit).sum();
255
256 let difference = (total_debits - total_credits).abs();
257 if difference > BALANCE_TOLERANCE {
258 return Err(JournalEntryError::Unbalanced {
259 debits: total_debits,
260 credits: total_credits,
261 difference,
262 tolerance: BALANCE_TOLERANCE,
263 });
264 }
265
266 Ok(())
267 }
268
269 fn validate_line(line: &JournalEntryLine) -> Result<(), JournalEntryError> {
271 if line.debit > Decimal::ZERO && line.credit > Decimal::ZERO {
273 return Err(JournalEntryError::LineHasBothDebitAndCredit);
274 }
275
276 if line.debit == Decimal::ZERO && line.credit == Decimal::ZERO {
277 return Err(JournalEntryError::LineHasNeitherDebitNorCredit);
278 }
279
280 if line.debit < Decimal::ZERO || line.credit < Decimal::ZERO {
282 return Err(JournalEntryError::NegativeAmount);
283 }
284
285 if line.account_code.trim().is_empty() {
287 return Err(JournalEntryError::MissingAccountCode);
288 }
289
290 Ok(())
291 }
292
293 pub fn total_debits(&self) -> Decimal {
295 self.lines.iter().map(|l| l.debit).sum()
296 }
297
298 pub fn total_credits(&self) -> Decimal {
300 self.lines.iter().map(|l| l.credit).sum()
301 }
302
303 pub fn is_balanced(&self) -> bool {
305 (self.total_debits() - self.total_credits()).abs() <= BALANCE_TOLERANCE
306 }
307}
308
309impl JournalEntryLine {
310 pub fn new_debit(
312 journal_entry_id: Uuid,
313 organization_id: Uuid,
314 account_code: String,
315 amount: Decimal,
316 description: Option<String>,
317 ) -> Result<Self, JournalEntryError> {
318 if amount <= Decimal::ZERO {
319 return Err(JournalEntryError::NonPositiveDebit);
320 }
321
322 Ok(Self {
323 id: Uuid::new_v4(),
324 journal_entry_id,
325 organization_id,
326 account_code,
327 debit: amount,
328 credit: Decimal::ZERO,
329 description,
330 created_at: Utc::now(),
331 })
332 }
333
334 pub fn new_credit(
336 journal_entry_id: Uuid,
337 organization_id: Uuid,
338 account_code: String,
339 amount: Decimal,
340 description: Option<String>,
341 ) -> Result<Self, JournalEntryError> {
342 if amount <= Decimal::ZERO {
343 return Err(JournalEntryError::NonPositiveCredit);
344 }
345
346 Ok(Self {
347 id: Uuid::new_v4(),
348 journal_entry_id,
349 organization_id,
350 account_code,
351 debit: Decimal::ZERO,
352 credit: amount,
353 description,
354 created_at: Utc::now(),
355 })
356 }
357
358 pub fn amount(&self) -> Decimal {
360 if self.debit > Decimal::ZERO {
361 self.debit
362 } else {
363 self.credit
364 }
365 }
366
367 pub fn is_debit(&self) -> bool {
369 self.debit > Decimal::ZERO
370 }
371
372 pub fn is_credit(&self) -> bool {
374 self.credit > Decimal::ZERO
375 }
376}
377
378impl crate::domain::services::PieceDeGestion for JournalEntry {
379 fn acp_id(&self) -> Uuid {
380 self.acp_id
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_journal_entry_balanced() {
390 let org_id = Uuid::new_v4();
391 let entry_id = Uuid::new_v4();
392
393 let lines = vec![
395 JournalEntryLine::new_debit(
396 entry_id,
397 org_id,
398 "6100".to_string(),
399 dec!(1000),
400 Some("Utilities".to_string()),
401 )
402 .unwrap(),
403 JournalEntryLine::new_debit(
404 entry_id,
405 org_id,
406 "4110".to_string(),
407 dec!(210),
408 Some("VAT 21%".to_string()),
409 )
410 .unwrap(),
411 JournalEntryLine::new_credit(
412 entry_id,
413 org_id,
414 "4400".to_string(),
415 dec!(1210),
416 Some("Supplier".to_string()),
417 )
418 .unwrap(),
419 ];
420
421 let entry = JournalEntry::new(
422 Uuid::new_v4(), org_id,
424 None, Utc::now(),
426 Some("Facture eau".to_string()),
427 Some("INV-2025-001".to_string()),
428 Some("ACH".to_string()), None, None, lines,
432 None, );
434
435 assert!(entry.is_ok());
436 let entry = entry.unwrap();
437 assert!(entry.is_balanced());
438 assert_eq!(entry.total_debits(), dec!(1210));
439 assert_eq!(entry.total_credits(), dec!(1210));
440 }
441
442 #[test]
443 fn test_journal_entry_unbalanced() {
444 let org_id = Uuid::new_v4();
445 let entry_id = Uuid::new_v4();
446
447 let lines = vec![
449 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(1000), None)
450 .unwrap(),
451 JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(900), None)
452 .unwrap(),
453 ];
454
455 let entry = JournalEntry::new(
456 Uuid::new_v4(), org_id,
458 None, Utc::now(),
460 Some("Test".to_string()),
461 None, None, None, None, lines,
466 None, );
468
469 assert!(entry.is_err());
470 assert!(matches!(
471 entry.unwrap_err(),
472 JournalEntryError::Unbalanced { .. }
473 ));
474 }
475
476 #[test]
477 fn test_journal_entry_line_cannot_have_both_debit_and_credit() {
478 let org_id = Uuid::new_v4();
479 let entry_id = Uuid::new_v4();
480
481 let invalid_line = JournalEntryLine {
483 id: Uuid::new_v4(),
484 journal_entry_id: entry_id,
485 organization_id: org_id,
486 account_code: "6100".to_string(),
487 debit: dec!(100),
488 credit: dec!(100), description: None,
490 created_at: Utc::now(),
491 };
492
493 let entry = JournalEntry::new(
494 Uuid::new_v4(), org_id,
496 None,
497 Utc::now(),
498 Some("Test".to_string()),
499 None,
500 None,
501 None,
502 None,
503 vec![invalid_line],
504 None,
505 );
506
507 assert!(entry.is_err());
508 assert!(matches!(
509 entry.unwrap_err(),
510 JournalEntryError::LineHasBothDebitAndCredit
511 ));
512 }
513
514 #[test]
515 fn test_journal_entry_line_must_have_amount() {
516 let org_id = Uuid::new_v4();
517 let entry_id = Uuid::new_v4();
518
519 let invalid_line = JournalEntryLine {
521 id: Uuid::new_v4(),
522 journal_entry_id: entry_id,
523 organization_id: org_id,
524 account_code: "6100".to_string(),
525 debit: Decimal::ZERO,
526 credit: Decimal::ZERO, description: None,
528 created_at: Utc::now(),
529 };
530
531 let entry = JournalEntry::new(
532 Uuid::new_v4(), org_id,
534 None,
535 Utc::now(),
536 Some("Test".to_string()),
537 None,
538 None,
539 None,
540 None,
541 vec![invalid_line],
542 None,
543 );
544
545 assert!(entry.is_err());
546 assert!(matches!(
547 entry.unwrap_err(),
548 JournalEntryError::LineHasNeitherDebitNorCredit
549 ));
550 }
551
552 #[test]
553 fn test_rounding_tolerance() {
554 let org_id = Uuid::new_v4();
555 let entry_id = Uuid::new_v4();
556
557 let lines = vec![
559 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(100.33), None)
560 .unwrap(),
561 JournalEntryLine::new_credit(
562 entry_id,
563 org_id,
564 "4400".to_string(),
565 dec!(100.34), None,
567 )
568 .unwrap(),
569 ];
570
571 let entry = JournalEntry::new(
572 Uuid::new_v4(), org_id,
574 None,
575 Utc::now(),
576 Some("Test rounding".to_string()),
577 None,
578 None,
579 None,
580 None,
581 lines,
582 None,
583 );
584
585 if entry.is_err() {
586 eprintln!("Error: {:?}", entry.as_ref().err());
587 }
588 assert!(entry.is_ok());
589 assert!(entry.unwrap().is_balanced());
590 }
591
592 #[test]
595 fn edge_decimal_exactness_preserved_on_cumul() {
596 let org_id = Uuid::new_v4();
597 let entry_id = Uuid::new_v4();
598
599 let lines = vec![
600 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(0.1), None)
601 .unwrap(),
602 JournalEntryLine::new_debit(entry_id, org_id, "6101".to_string(), dec!(0.2), None)
603 .unwrap(),
604 JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(0.3), None)
605 .unwrap(),
606 ];
607
608 let entry = JournalEntry::new(
609 Uuid::new_v4(), org_id,
611 None,
612 Utc::now(),
613 None,
614 None,
615 None,
616 None,
617 None,
618 lines,
619 None,
620 )
621 .expect("0.1 + 0.2 = 0.3 must balance exactly with Decimal");
622
623 assert_eq!(entry.total_debits(), dec!(0.3));
624 assert_eq!(entry.total_credits(), dec!(0.3));
625 assert!(entry.is_balanced());
626 }
627
628 #[test]
630 fn negative_debit_amount_rejected() {
631 let result = JournalEntryLine::new_debit(
632 Uuid::new_v4(),
633 Uuid::new_v4(),
634 "6100".to_string(),
635 dec!(-1),
636 None,
637 );
638 assert!(result.is_err());
639 assert!(matches!(
640 result.unwrap_err(),
641 JournalEntryError::NonPositiveDebit
642 ));
643 }
644
645 #[test]
648 fn security_cross_org_line_rejected() {
649 let org_id = Uuid::new_v4();
650 let other_org_id = Uuid::new_v4();
651 let entry_id = Uuid::new_v4();
652
653 let lines = vec![
656 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(100), None)
657 .unwrap(),
658 JournalEntryLine::new_credit(
659 entry_id,
660 other_org_id,
661 "4400".to_string(),
662 dec!(100),
663 None,
664 )
665 .unwrap(),
666 ];
667
668 let result = JournalEntry::new(
669 Uuid::new_v4(), org_id,
671 None,
672 Utc::now(),
673 None,
674 None,
675 None,
676 None,
677 None,
678 lines,
679 None,
680 );
681
682 assert!(result.is_err());
683 assert!(matches!(
684 result.unwrap_err(),
685 JournalEntryError::CrossOrgLine
686 ));
687 }
688}