1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct JournalEntry {
29 pub id: Uuid,
30 pub organization_id: Uuid,
31 pub building_id: Option<Uuid>,
33 pub entry_date: DateTime<Utc>,
35 pub description: Option<String>,
37 pub document_ref: Option<String>,
39 pub journal_type: Option<String>,
42 pub expense_id: Option<Uuid>,
44 pub contribution_id: Option<Uuid>,
46 pub lines: Vec<JournalEntryLine>,
48 pub created_at: DateTime<Utc>,
49 pub updated_at: DateTime<Utc>,
50 pub created_by: Option<Uuid>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct JournalEntryLine {
58 pub id: Uuid,
59 pub journal_entry_id: Uuid,
60 pub organization_id: Uuid,
61 pub account_code: String,
63 pub debit: f64,
65 pub credit: f64,
67 pub description: Option<String>,
69 pub created_at: DateTime<Utc>,
70}
71
72impl JournalEntry {
73 #[allow(clippy::too_many_arguments)]
85 pub fn new(
86 organization_id: Uuid,
87 building_id: Option<Uuid>,
88 entry_date: DateTime<Utc>,
89 description: Option<String>,
90 document_ref: Option<String>,
91 journal_type: Option<String>,
92 expense_id: Option<Uuid>,
93 contribution_id: Option<Uuid>,
94 lines: Vec<JournalEntryLine>,
95 created_by: Option<Uuid>,
96 ) -> Result<Self, String> {
97 Self::validate_lines_balance(&lines)?;
99
100 for line in &lines {
102 Self::validate_line(line)?;
103 }
104
105 if let Some(ref jtype) = journal_type {
107 if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
108 return Err(format!(
109 "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
110 jtype
111 ));
112 }
113 }
114
115 let now = Utc::now();
116 Ok(Self {
117 id: Uuid::new_v4(),
118 organization_id,
119 building_id,
120 entry_date,
121 description,
122 document_ref,
123 journal_type,
124 expense_id,
125 contribution_id,
126 lines,
127 created_at: now,
128 updated_at: now,
129 created_by,
130 })
131 }
132
133 fn validate_lines_balance(lines: &[JournalEntryLine]) -> Result<(), String> {
135 if lines.is_empty() {
136 return Err("Journal entry must have at least one line".to_string());
137 }
138
139 let total_debits: f64 = lines.iter().map(|l| l.debit).sum();
140 let total_credits: f64 = lines.iter().map(|l| l.credit).sum();
141
142 let difference = (total_debits - total_credits).abs();
143 const TOLERANCE: f64 = 0.011; if difference > TOLERANCE {
145 return Err(format!(
146 "Journal entry is unbalanced: debits={:.2}€, credits={:.2}€, difference={:.2}€ (tolerance: {:.2}€)",
147 total_debits, total_credits, difference, TOLERANCE
148 ));
149 }
150
151 Ok(())
152 }
153
154 fn validate_line(line: &JournalEntryLine) -> Result<(), String> {
156 if line.debit > 0.0 && line.credit > 0.0 {
158 return Err("Line cannot have both debit and credit".to_string());
159 }
160
161 if line.debit == 0.0 && line.credit == 0.0 {
162 return Err("Line must have either debit or credit".to_string());
163 }
164
165 if line.debit < 0.0 || line.credit < 0.0 {
167 return Err("Debit and credit amounts must be non-negative".to_string());
168 }
169
170 if line.account_code.trim().is_empty() {
172 return Err("Account code is required".to_string());
173 }
174
175 Ok(())
176 }
177
178 pub fn total_debits(&self) -> f64 {
180 self.lines.iter().map(|l| l.debit).sum()
181 }
182
183 pub fn total_credits(&self) -> f64 {
185 self.lines.iter().map(|l| l.credit).sum()
186 }
187
188 pub fn is_balanced(&self) -> bool {
190 const TOLERANCE: f64 = 0.011; (self.total_debits() - self.total_credits()).abs() <= TOLERANCE
192 }
193}
194
195impl JournalEntryLine {
196 pub fn new_debit(
198 journal_entry_id: Uuid,
199 organization_id: Uuid,
200 account_code: String,
201 amount: f64,
202 description: Option<String>,
203 ) -> Result<Self, String> {
204 if amount <= 0.0 {
205 return Err("Debit amount must be positive".to_string());
206 }
207
208 Ok(Self {
209 id: Uuid::new_v4(),
210 journal_entry_id,
211 organization_id,
212 account_code,
213 debit: amount,
214 credit: 0.0,
215 description,
216 created_at: Utc::now(),
217 })
218 }
219
220 pub fn new_credit(
222 journal_entry_id: Uuid,
223 organization_id: Uuid,
224 account_code: String,
225 amount: f64,
226 description: Option<String>,
227 ) -> Result<Self, String> {
228 if amount <= 0.0 {
229 return Err("Credit amount must be positive".to_string());
230 }
231
232 Ok(Self {
233 id: Uuid::new_v4(),
234 journal_entry_id,
235 organization_id,
236 account_code,
237 debit: 0.0,
238 credit: amount,
239 description,
240 created_at: Utc::now(),
241 })
242 }
243
244 pub fn amount(&self) -> f64 {
246 if self.debit > 0.0 {
247 self.debit
248 } else {
249 self.credit
250 }
251 }
252
253 pub fn is_debit(&self) -> bool {
255 self.debit > 0.0
256 }
257
258 pub fn is_credit(&self) -> bool {
260 self.credit > 0.0
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn test_journal_entry_balanced() {
270 let org_id = Uuid::new_v4();
271 let entry_id = Uuid::new_v4();
272
273 let lines = vec![
275 JournalEntryLine::new_debit(
276 entry_id,
277 org_id,
278 "6100".to_string(),
279 1000.0,
280 Some("Utilities".to_string()),
281 )
282 .unwrap(),
283 JournalEntryLine::new_debit(
284 entry_id,
285 org_id,
286 "4110".to_string(),
287 210.0,
288 Some("VAT 21%".to_string()),
289 )
290 .unwrap(),
291 JournalEntryLine::new_credit(
292 entry_id,
293 org_id,
294 "4400".to_string(),
295 1210.0,
296 Some("Supplier".to_string()),
297 )
298 .unwrap(),
299 ];
300
301 let entry = JournalEntry::new(
302 org_id,
303 None, Utc::now(),
305 Some("Facture eau".to_string()),
306 Some("INV-2025-001".to_string()),
307 Some("ACH".to_string()), None, None, lines,
311 None, );
313
314 assert!(entry.is_ok());
315 let entry = entry.unwrap();
316 assert!(entry.is_balanced());
317 assert_eq!(entry.total_debits(), 1210.0);
318 assert_eq!(entry.total_credits(), 1210.0);
319 }
320
321 #[test]
322 fn test_journal_entry_unbalanced() {
323 let org_id = Uuid::new_v4();
324 let entry_id = Uuid::new_v4();
325
326 let lines = vec![
328 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), 1000.0, None)
329 .unwrap(),
330 JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), 900.0, None)
331 .unwrap(),
332 ];
333
334 let entry = JournalEntry::new(
335 org_id,
336 None, Utc::now(),
338 Some("Test".to_string()),
339 None, None, None, None, lines,
344 None, );
346
347 assert!(entry.is_err());
348 assert!(entry.unwrap_err().contains("unbalanced"));
349 }
350
351 #[test]
352 fn test_journal_entry_line_cannot_have_both_debit_and_credit() {
353 let org_id = Uuid::new_v4();
354 let entry_id = Uuid::new_v4();
355
356 let invalid_line = JournalEntryLine {
358 id: Uuid::new_v4(),
359 journal_entry_id: entry_id,
360 organization_id: org_id,
361 account_code: "6100".to_string(),
362 debit: 100.0,
363 credit: 100.0, description: None,
365 created_at: Utc::now(),
366 };
367
368 let entry = JournalEntry::new(
369 org_id,
370 None,
371 Utc::now(),
372 Some("Test".to_string()),
373 None,
374 None,
375 None,
376 None,
377 vec![invalid_line],
378 None,
379 );
380
381 assert!(entry.is_err());
382 assert!(entry.unwrap_err().contains("both debit and credit"));
383 }
384
385 #[test]
386 fn test_journal_entry_line_must_have_amount() {
387 let org_id = Uuid::new_v4();
388 let entry_id = Uuid::new_v4();
389
390 let invalid_line = JournalEntryLine {
392 id: Uuid::new_v4(),
393 journal_entry_id: entry_id,
394 organization_id: org_id,
395 account_code: "6100".to_string(),
396 debit: 0.0,
397 credit: 0.0, description: None,
399 created_at: Utc::now(),
400 };
401
402 let entry = JournalEntry::new(
403 org_id,
404 None,
405 Utc::now(),
406 Some("Test".to_string()),
407 None,
408 None,
409 None,
410 None,
411 vec![invalid_line],
412 None,
413 );
414
415 assert!(entry.is_err());
416 assert!(entry.unwrap_err().contains("either debit or credit"));
417 }
418
419 #[test]
420 fn test_rounding_tolerance() {
421 let org_id = Uuid::new_v4();
422 let entry_id = Uuid::new_v4();
423
424 let lines = vec![
426 JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), 100.33, None)
427 .unwrap(),
428 JournalEntryLine::new_credit(
429 entry_id,
430 org_id,
431 "4400".to_string(),
432 100.34, None,
434 )
435 .unwrap(),
436 ];
437
438 let entry = JournalEntry::new(
439 org_id,
440 None,
441 Utc::now(),
442 Some("Test rounding".to_string()),
443 None,
444 None,
445 None,
446 None,
447 lines,
448 None,
449 );
450
451 if entry.is_err() {
452 eprintln!("Error: {:?}", entry.as_ref().err());
453 }
454 assert!(entry.is_ok());
455 assert!(entry.unwrap().is_balanced());
456 }
457}