koprogo_api/domain/entities/
invoice_line_item.rs1use chrono::{DateTime, Utc};
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct InvoiceLineItem {
22 pub id: Uuid,
23 pub expense_id: Uuid, pub description: String,
25 pub quantity: Decimal,
26 pub unit_price: Decimal, pub amount_excl_vat: Decimal, pub vat_rate: Decimal, pub vat_amount: Decimal, pub amount_incl_vat: Decimal, pub created_at: DateTime<Utc>,
35}
36
37impl InvoiceLineItem {
38 pub fn new(
39 expense_id: Uuid,
40 description: String,
41 quantity: Decimal,
42 unit_price: Decimal,
43 vat_rate: Decimal,
44 ) -> Result<Self, String> {
45 if description.trim().is_empty() {
47 return Err("Description cannot be empty".to_string());
48 }
49 if quantity <= Decimal::ZERO {
50 return Err("Quantity must be greater than 0".to_string());
51 }
52 if unit_price < Decimal::ZERO {
53 return Err("Unit price cannot be negative".to_string());
54 }
55 if vat_rate < Decimal::ZERO || vat_rate > dec!(100) {
56 return Err("VAT rate must be between 0 and 100".to_string());
57 }
58
59 let amount_excl_vat = quantity * unit_price;
61 let vat_amount = (amount_excl_vat * vat_rate) / dec!(100);
62 let amount_incl_vat = amount_excl_vat + vat_amount;
63
64 Ok(Self {
65 id: Uuid::new_v4(),
66 expense_id,
67 description: description.trim().to_string(),
68 quantity,
69 unit_price,
70 amount_excl_vat,
71 vat_rate,
72 vat_amount,
73 amount_incl_vat,
74 created_at: Utc::now(),
75 })
76 }
77
78 pub fn recalculate(&mut self) -> Result<(), String> {
80 if self.quantity <= Decimal::ZERO {
81 return Err("Quantity must be greater than 0".to_string());
82 }
83 if self.unit_price < Decimal::ZERO {
84 return Err("Unit price cannot be negative".to_string());
85 }
86 if self.vat_rate < Decimal::ZERO || self.vat_rate > dec!(100) {
87 return Err("VAT rate must be between 0 and 100".to_string());
88 }
89
90 self.amount_excl_vat = self.quantity * self.unit_price;
91 self.vat_amount = (self.amount_excl_vat * self.vat_rate) / dec!(100);
92 self.amount_incl_vat = self.amount_excl_vat + self.vat_amount;
93 Ok(())
94 }
95
96 pub fn total_excl_vat(items: &[InvoiceLineItem]) -> Decimal {
99 items.iter().map(|item| item.amount_excl_vat).sum()
100 }
101
102 pub fn total_vat(items: &[InvoiceLineItem]) -> Decimal {
104 items.iter().map(|item| item.vat_amount).sum()
105 }
106
107 pub fn total_incl_vat(items: &[InvoiceLineItem]) -> Decimal {
109 items.iter().map(|item| item.amount_incl_vat).sum()
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
122 fn happy_create_line_item_success() {
123 let expense_id = Uuid::new_v4();
124 let line = InvoiceLineItem::new(
125 expense_id,
126 "Réparation porte principale".to_string(),
127 dec!(2), dec!(150), dec!(21), );
131
132 assert!(line.is_ok());
133 let line = line.unwrap();
134 assert_eq!(line.expense_id, expense_id);
135 assert_eq!(line.quantity, dec!(2));
136 assert_eq!(line.unit_price, dec!(150));
137 assert_eq!(line.amount_excl_vat, dec!(300)); assert_eq!(line.vat_amount, dec!(63)); assert_eq!(line.amount_incl_vat, dec!(363)); }
141
142 #[test]
143 fn happy_create_line_item_with_vat_6_percent() {
144 let expense_id = Uuid::new_v4();
145 let line = InvoiceLineItem::new(
146 expense_id,
147 "Travaux isolation toit".to_string(),
148 dec!(1),
149 dec!(10000),
150 dec!(6), )
152 .unwrap();
153
154 assert_eq!(line.amount_excl_vat, dec!(10000));
155 assert_eq!(line.vat_amount, dec!(600)); assert_eq!(line.amount_incl_vat, dec!(10600));
157 }
158
159 #[test]
160 fn happy_total_calculations_multiple_lines() {
161 let expense_id = Uuid::new_v4();
162
163 let lines = vec![
164 InvoiceLineItem::new(
165 expense_id,
166 "Item 1".to_string(),
167 dec!(2),
168 dec!(100),
169 dec!(21),
170 )
171 .unwrap(),
172 InvoiceLineItem::new(
173 expense_id,
174 "Item 2".to_string(),
175 dec!(1),
176 dec!(300),
177 dec!(21),
178 )
179 .unwrap(),
180 InvoiceLineItem::new(expense_id, "Item 3".to_string(), dec!(3), dec!(50), dec!(6))
181 .unwrap(),
182 ];
183
184 assert_eq!(InvoiceLineItem::total_excl_vat(&lines), dec!(650));
189 assert_eq!(InvoiceLineItem::total_vat(&lines), dec!(114));
190 assert_eq!(InvoiceLineItem::total_incl_vat(&lines), dec!(764));
191 }
192
193 #[test]
198 fn edge_total_calculations_empty_list() {
199 let lines: Vec<InvoiceLineItem> = vec![];
200 assert_eq!(InvoiceLineItem::total_excl_vat(&lines), Decimal::ZERO);
201 assert_eq!(InvoiceLineItem::total_vat(&lines), Decimal::ZERO);
202 assert_eq!(InvoiceLineItem::total_incl_vat(&lines), Decimal::ZERO);
203 }
204
205 #[test]
206 fn edge_description_trimmed() {
207 let expense_id = Uuid::new_v4();
208 let line = InvoiceLineItem::new(
209 expense_id,
210 " Peinture couloir ".to_string(),
211 dec!(1),
212 dec!(500),
213 dec!(21),
214 )
215 .unwrap();
216 assert_eq!(line.description, "Peinture couloir");
217 }
218
219 #[test]
220 fn edge_recalculate_after_quantity_change() {
221 let expense_id = Uuid::new_v4();
222 let mut line =
223 InvoiceLineItem::new(expense_id, "Test".to_string(), dec!(1), dec!(100), dec!(21))
224 .unwrap();
225
226 assert_eq!(line.amount_excl_vat, dec!(100));
227
228 line.quantity = dec!(3);
229 line.recalculate().unwrap();
230
231 assert_eq!(line.amount_excl_vat, dec!(300));
232 assert_eq!(line.vat_amount, dec!(63));
233 assert_eq!(line.amount_incl_vat, dec!(363));
234 }
235
236 #[test]
237 fn edge_recalculate_after_unit_price_change() {
238 let expense_id = Uuid::new_v4();
239 let mut line =
240 InvoiceLineItem::new(expense_id, "Test".to_string(), dec!(2), dec!(100), dec!(21))
241 .unwrap();
242
243 line.unit_price = dec!(200);
244 line.recalculate().unwrap();
245
246 assert_eq!(line.amount_excl_vat, dec!(400));
247 assert_eq!(line.vat_amount, dec!(84));
248 assert_eq!(line.amount_incl_vat, dec!(484));
249 }
250
251 #[test]
252 fn edge_decimal_exactness_preserved_on_cumul() {
253 let expense_id = Uuid::new_v4();
256 let lines = vec![
257 InvoiceLineItem::new(
258 expense_id,
259 "Item small".to_string(),
260 dec!(1),
261 dec!(0.1),
262 dec!(0),
263 )
264 .unwrap(),
265 InvoiceLineItem::new(
266 expense_id,
267 "Item small 2".to_string(),
268 dec!(1),
269 dec!(0.2),
270 dec!(0),
271 )
272 .unwrap(),
273 ];
274
275 assert_eq!(InvoiceLineItem::total_excl_vat(&lines), dec!(0.3));
276 }
277
278 #[test]
279 fn edge_vat_rate_zero_allowed() {
280 let expense_id = Uuid::new_v4();
281 let line = InvoiceLineItem::new(
282 expense_id,
283 "Exonéré TVA".to_string(),
284 dec!(1),
285 dec!(100),
286 Decimal::ZERO,
287 )
288 .unwrap();
289 assert_eq!(line.vat_amount, Decimal::ZERO);
290 assert_eq!(line.amount_incl_vat, dec!(100));
291 }
292
293 #[test]
294 fn edge_vat_rate_max_100_allowed() {
295 let expense_id = Uuid::new_v4();
296 let line = InvoiceLineItem::new(
297 expense_id,
298 "Border case".to_string(),
299 dec!(1),
300 dec!(100),
301 dec!(100),
302 )
303 .unwrap();
304 assert_eq!(line.vat_amount, dec!(100)); assert_eq!(line.amount_incl_vat, dec!(200));
306 }
307
308 #[test]
315 fn security_create_line_item_empty_description_fails() {
316 let expense_id = Uuid::new_v4();
317 let line =
318 InvoiceLineItem::new(expense_id, " ".to_string(), dec!(1), dec!(100), dec!(21));
319 assert!(line.is_err());
320 assert_eq!(line.unwrap_err(), "Description cannot be empty");
321 }
322
323 #[test]
324 fn security_create_line_item_negative_unit_price_fails() {
325 let expense_id = Uuid::new_v4();
328 let line =
329 InvoiceLineItem::new(expense_id, "Test".to_string(), dec!(1), dec!(-50), dec!(21));
330 assert!(line.is_err());
331 assert_eq!(line.unwrap_err(), "Unit price cannot be negative");
332 }
333
334 #[test]
335 fn security_create_line_item_vat_rate_above_100_fails() {
336 let expense_id = Uuid::new_v4();
339 let line = InvoiceLineItem::new(
340 expense_id,
341 "Test".to_string(),
342 dec!(1),
343 dec!(100),
344 dec!(150),
345 );
346 assert!(line.is_err());
347 }
348
349 #[test]
350 fn security_create_line_item_negative_vat_rate_fails() {
351 let expense_id = Uuid::new_v4();
352 let line =
353 InvoiceLineItem::new(expense_id, "Test".to_string(), dec!(1), dec!(100), dec!(-1));
354 assert!(line.is_err());
355 }
356
357 #[test]
362 fn negative_create_line_item_zero_quantity_fails() {
363 let expense_id = Uuid::new_v4();
364 let line = InvoiceLineItem::new(
365 expense_id,
366 "Test".to_string(),
367 Decimal::ZERO,
368 dec!(100),
369 dec!(21),
370 );
371 assert!(line.is_err());
372 assert_eq!(line.unwrap_err(), "Quantity must be greater than 0");
373 }
374
375 #[test]
376 fn negative_recalculate_with_invalid_state_returns_error() {
377 let expense_id = Uuid::new_v4();
380 let mut line =
381 InvoiceLineItem::new(expense_id, "Test".to_string(), dec!(1), dec!(100), dec!(21))
382 .unwrap();
383
384 line.quantity = dec!(-5); let result = line.recalculate();
386 assert!(result.is_err());
387 assert_eq!(result.unwrap_err(), "Quantity must be greater than 0");
388 }
389
390 #[test]
391 fn negative_no_panic_on_extreme_values() {
392 let expense_id = Uuid::new_v4();
395 let line = InvoiceLineItem::new(
397 expense_id,
398 "Big".to_string(),
399 dec!(1),
400 dec!(99999999999.99), dec!(21),
402 )
403 .unwrap();
404 assert!(line.amount_incl_vat > line.amount_excl_vat);
406 }
407
408 #[test]
409 fn negative_description_only_whitespace_fails() {
410 let expense_id = Uuid::new_v4();
411 let line = InvoiceLineItem::new(
412 expense_id,
413 "\t\n ".to_string(),
414 dec!(1),
415 dec!(100),
416 dec!(21),
417 );
418 assert!(line.is_err());
419 }
420}