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 pub organization_id: Uuid,
36 pub building_id: Option<Uuid>,
38 pub entry_date: DateTime<Utc>,
40 pub description: Option<String>,
42 pub document_ref: Option<String>,
44 pub journal_type: Option<String>,
47 pub expense_id: Option<Uuid>,
49 pub contribution_id: Option<Uuid>,
51 pub lines: Vec<JournalEntryLine>,
53 pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
55 pub created_by: Option<Uuid>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct JournalEntryLine {
63 pub id: Uuid,
64 pub journal_entry_id: Uuid,
65 pub organization_id: Uuid,
66 pub account_code: String,
68 pub debit: Decimal,
70 pub credit: Decimal,
72 pub description: Option<String>,
74 pub created_at: DateTime<Utc>,
75}
76
77const BALANCE_TOLERANCE: Decimal = dec!(0.011);
79
80impl JournalEntry {
81 #[allow(clippy::too_many_arguments)]
93 pub fn new(
94 organization_id: Uuid,
95 building_id: Option<Uuid>,
96 entry_date: DateTime<Utc>,
97 description: Option<String>,
98 document_ref: Option<String>,
99 journal_type: Option<String>,
100 expense_id: Option<Uuid>,
101 contribution_id: Option<Uuid>,
102 lines: Vec<JournalEntryLine>,
103 created_by: Option<Uuid>,
104 ) -> Result<Self, String> {
105 Self::validate_lines_balance(&lines)?;
107
108 for line in &lines {
110 Self::validate_line(line)?;
111 }
112
113 if let Some(ref jtype) = journal_type {
115 if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
116 return Err(format!(
117 "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
118 jtype
119 ));
120 }
121 }
122
123 let now = Utc::now();
124 Ok(Self {
125 id: Uuid::new_v4(),
126 organization_id,
127 building_id,
128 entry_date,
129 description,
130 document_ref,
131 journal_type,
132 expense_id,
133 contribution_id,
134 lines,
135 created_at: now,
136 updated_at: now,
137 created_by,
138 })
139 }
140
141 fn validate_lines_balance(lines: &[JournalEntryLine]) -> Result<(), String> {
143 if lines.is_empty() {
144 return Err("Journal entry must have at least one line".to_string());
145 }
146
147 let total_debits: Decimal = lines.iter().map(|l| l.debit).sum();
148 let total_credits: Decimal = lines.iter().map(|l| l.credit).sum();
149
150 let difference = (total_debits - total_credits).abs();
151 if difference > BALANCE_TOLERANCE {
152 return Err(format!(
153 "Journal entry is unbalanced: debits={}€, credits={}€, difference={}€ (tolerance: {}€)",
154 total_debits, total_credits, difference, BALANCE_TOLERANCE
155 ));
156 }
157
158 Ok(())
159 }
160
161 fn validate_line(line: &JournalEntryLine) -> Result<(), String> {
163 if line.debit > Decimal::ZERO && line.credit > Decimal::ZERO {
165 return Err("Line cannot have both debit and credit".to_string());
166 }
167
168 if line.debit == Decimal::ZERO && line.credit == Decimal::ZERO {
169 return Err("Line must have either debit or credit".to_string());
170 }
171
172 if line.debit < Decimal::ZERO || line.credit < Decimal::ZERO {
174 return Err("Debit and credit amounts must be non-negative".to_string());
175 }
176
177 if line.account_code.trim().is_empty() {
179 return Err("Account code is required".to_string());
180 }
181
182 Ok(())
183 }
184
185 pub fn total_debits(&self) -> Decimal {
187 self.lines.iter().map(|l| l.debit).sum()
188 }
189
190 pub fn total_credits(&self) -> Decimal {
192 self.lines.iter().map(|l| l.credit).sum()
193 }
194
195 pub fn is_balanced(&self) -> bool {
197 (self.total_debits() - self.total_credits()).abs() <= BALANCE_TOLERANCE
198 }
199}
200
201impl JournalEntryLine {
202 pub fn new_debit(
204 journal_entry_id: Uuid,
205 organization_id: Uuid,
206 account_code: String,
207 amount: Decimal,
208 description: Option<String>,
209 ) -> Result<Self, String> {
210 if amount <= Decimal::ZERO {
211 return Err("Debit amount must be positive".to_string());
212 }
213
214 Ok(Self {
215 id: Uuid::new_v4(),
216 journal_entry_id,
217 organization_id,
218 account_code,
219 debit: amount,
220 credit: Decimal::ZERO,
221 description,
222 created_at: Utc::now(),
223 })
224 }
225
226 pub fn new_credit(
228 journal_entry_id: Uuid,
229 organization_id: Uuid,
230 account_code: String,
231 amount: Decimal,
232 description: Option<String>,
233 ) -> Result<Self, String> {
234 if amount <= Decimal::ZERO {
235 return Err("Credit amount must be positive".to_string());
236 }
237
238 Ok(Self {
239 id: Uuid::new_v4(),
240 journal_entry_id,
241 organization_id,
242 account_code,
243 debit: Decimal::ZERO,
244 credit: amount,
245 description,
246 created_at: Utc::now(),
247 })
248 }
249
250 pub fn amount(&self) -> Decimal {
252 if self.debit > Decimal::ZERO {
253 self.debit
254 } else {
255 self.credit
256 }
257 }
258
259 pub fn is_debit(&self) -> bool {
261 self.debit > Decimal::ZERO
262 }
263
264 pub fn is_credit(&self) -> bool {
266 self.credit > Decimal::ZERO
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_journal_entry_balanced() {
276 let org_id = Uuid::new_v4();
277 let entry_id = Uuid::new_v4();
278
279 let lines = vec![
281 JournalEntryLine::new_debit(
282 entry_id,
283 org_id,
284 "6100".to_string(),
285 dec!(1000),
286 Some("Utilities".to_string()),
287 )
288 .unwrap(),
289 JournalEntryLine::new_debit(
290 entry_id,
291 org_id,
292 "4110".to_string(),
293 dec!(210),
294 Some("VAT 21%".to_string()),
295 )
296 .unwrap(),
297 JournalEntryLine::new_credit(
298 entry_id,
299 org_id,
300 "4400".to_string(),
301 dec!(1210),
302 Some("Supplier".to_string()),
303 )
304 .unwrap(),
305 ];
306
307 let entry = JournalEntry::new(
308 org_id,
309 None, Utc::now(),
311 Some("Facture eau".to_string()),
312 Some("INV-2025-001".to_string()),
313 Some("ACH".to_string()), None, None, lines,
317 None, );
319
320 assert!(entry.is_ok());
321 let entry = entry.unwrap();
322 assert!(entry.is_balanced());
323 assert_eq!(entry.total_debits(), dec!(1210));
324 assert_eq!(entry.total_credits(), dec!(1210));
325 }
326
327 #[test]
328 fn test_journal_entry_unbalanced() {
329 let org_id = Uuid::new_v4();
330 let entry_id = Uuid::new_v4();
331
332 let lines = vec![
334 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(1000), None)
335 .unwrap(),
336 JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(900), None)
337 .unwrap(),
338 ];
339
340 let entry = JournalEntry::new(
341 org_id,
342 None, Utc::now(),
344 Some("Test".to_string()),
345 None, None, None, None, lines,
350 None, );
352
353 assert!(entry.is_err());
354 assert!(entry.unwrap_err().contains("unbalanced"));
355 }
356
357 #[test]
358 fn test_journal_entry_line_cannot_have_both_debit_and_credit() {
359 let org_id = Uuid::new_v4();
360 let entry_id = Uuid::new_v4();
361
362 let invalid_line = JournalEntryLine {
364 id: Uuid::new_v4(),
365 journal_entry_id: entry_id,
366 organization_id: org_id,
367 account_code: "6100".to_string(),
368 debit: dec!(100),
369 credit: dec!(100), description: None,
371 created_at: Utc::now(),
372 };
373
374 let entry = JournalEntry::new(
375 org_id,
376 None,
377 Utc::now(),
378 Some("Test".to_string()),
379 None,
380 None,
381 None,
382 None,
383 vec![invalid_line],
384 None,
385 );
386
387 assert!(entry.is_err());
388 assert!(entry.unwrap_err().contains("both debit and credit"));
389 }
390
391 #[test]
392 fn test_journal_entry_line_must_have_amount() {
393 let org_id = Uuid::new_v4();
394 let entry_id = Uuid::new_v4();
395
396 let invalid_line = JournalEntryLine {
398 id: Uuid::new_v4(),
399 journal_entry_id: entry_id,
400 organization_id: org_id,
401 account_code: "6100".to_string(),
402 debit: Decimal::ZERO,
403 credit: Decimal::ZERO, description: None,
405 created_at: Utc::now(),
406 };
407
408 let entry = JournalEntry::new(
409 org_id,
410 None,
411 Utc::now(),
412 Some("Test".to_string()),
413 None,
414 None,
415 None,
416 None,
417 vec![invalid_line],
418 None,
419 );
420
421 assert!(entry.is_err());
422 assert!(entry.unwrap_err().contains("either debit or credit"));
423 }
424
425 #[test]
426 fn test_rounding_tolerance() {
427 let org_id = Uuid::new_v4();
428 let entry_id = Uuid::new_v4();
429
430 let lines = vec![
432 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(100.33), None)
433 .unwrap(),
434 JournalEntryLine::new_credit(
435 entry_id,
436 org_id,
437 "4400".to_string(),
438 dec!(100.34), None,
440 )
441 .unwrap(),
442 ];
443
444 let entry = JournalEntry::new(
445 org_id,
446 None,
447 Utc::now(),
448 Some("Test rounding".to_string()),
449 None,
450 None,
451 None,
452 None,
453 lines,
454 None,
455 );
456
457 if entry.is_err() {
458 eprintln!("Error: {:?}", entry.as_ref().err());
459 }
460 assert!(entry.is_ok());
461 assert!(entry.unwrap().is_balanced());
462 }
463
464 #[test]
467 fn edge_decimal_exactness_preserved_on_cumul() {
468 let org_id = Uuid::new_v4();
469 let entry_id = Uuid::new_v4();
470
471 let lines = vec![
472 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(0.1), None)
473 .unwrap(),
474 JournalEntryLine::new_debit(entry_id, org_id, "6101".to_string(), dec!(0.2), None)
475 .unwrap(),
476 JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(0.3), None)
477 .unwrap(),
478 ];
479
480 let entry = JournalEntry::new(
481 org_id,
482 None,
483 Utc::now(),
484 None,
485 None,
486 None,
487 None,
488 None,
489 lines,
490 None,
491 )
492 .expect("0.1 + 0.2 = 0.3 must balance exactly with Decimal");
493
494 assert_eq!(entry.total_debits(), dec!(0.3));
495 assert_eq!(entry.total_credits(), dec!(0.3));
496 assert!(entry.is_balanced());
497 }
498
499 #[test]
501 fn negative_debit_amount_rejected() {
502 let result = JournalEntryLine::new_debit(
503 Uuid::new_v4(),
504 Uuid::new_v4(),
505 "6100".to_string(),
506 dec!(-1),
507 None,
508 );
509 assert!(result.is_err());
510 assert!(result.unwrap_err().contains("must be positive"));
511 }
512}