koprogo_api/domain/entities/
invoice_line_item.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct InvoiceLineItem {
9 pub id: Uuid,
10 pub expense_id: Uuid, pub description: String,
12 pub quantity: f64,
13 pub unit_price: f64, pub amount_excl_vat: f64, pub vat_rate: f64, pub vat_amount: f64, pub amount_incl_vat: f64, pub created_at: DateTime<Utc>,
22}
23
24impl InvoiceLineItem {
25 pub fn new(
26 expense_id: Uuid,
27 description: String,
28 quantity: f64,
29 unit_price: f64,
30 vat_rate: f64,
31 ) -> Result<Self, String> {
32 if description.trim().is_empty() {
34 return Err("Description cannot be empty".to_string());
35 }
36 if quantity <= 0.0 {
37 return Err("Quantity must be greater than 0".to_string());
38 }
39 if unit_price < 0.0 {
40 return Err("Unit price cannot be negative".to_string());
41 }
42 if !(0.0..=100.0).contains(&vat_rate) {
43 return Err("VAT rate must be between 0 and 100".to_string());
44 }
45
46 let amount_excl_vat = quantity * unit_price;
48 let vat_amount = (amount_excl_vat * vat_rate) / 100.0;
49 let amount_incl_vat = amount_excl_vat + vat_amount;
50
51 Ok(Self {
52 id: Uuid::new_v4(),
53 expense_id,
54 description: description.trim().to_string(),
55 quantity,
56 unit_price,
57 amount_excl_vat,
58 vat_rate,
59 vat_amount,
60 amount_incl_vat,
61 created_at: Utc::now(),
62 })
63 }
64
65 pub fn recalculate(&mut self) -> Result<(), String> {
67 if self.quantity <= 0.0 {
68 return Err("Quantity must be greater than 0".to_string());
69 }
70 if self.unit_price < 0.0 {
71 return Err("Unit price cannot be negative".to_string());
72 }
73 if self.vat_rate < 0.0 || self.vat_rate > 100.0 {
74 return Err("VAT rate must be between 0 and 100".to_string());
75 }
76
77 self.amount_excl_vat = self.quantity * self.unit_price;
78 self.vat_amount = (self.amount_excl_vat * self.vat_rate) / 100.0;
79 self.amount_incl_vat = self.amount_excl_vat + self.vat_amount;
80 Ok(())
81 }
82
83 pub fn total_excl_vat(items: &[InvoiceLineItem]) -> f64 {
85 items.iter().map(|item| item.amount_excl_vat).sum()
86 }
87
88 pub fn total_vat(items: &[InvoiceLineItem]) -> f64 {
90 items.iter().map(|item| item.vat_amount).sum()
91 }
92
93 pub fn total_incl_vat(items: &[InvoiceLineItem]) -> f64 {
95 items.iter().map(|item| item.amount_incl_vat).sum()
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn test_create_line_item_success() {
105 let expense_id = Uuid::new_v4();
106 let line = InvoiceLineItem::new(
107 expense_id,
108 "Réparation porte principale".to_string(),
109 2.0, 150.0, 21.0, );
113
114 assert!(line.is_ok());
115 let line = line.unwrap();
116 assert_eq!(line.expense_id, expense_id);
117 assert_eq!(line.quantity, 2.0);
118 assert_eq!(line.unit_price, 150.0);
119 assert_eq!(line.amount_excl_vat, 300.0); assert_eq!(line.vat_amount, 63.0); assert_eq!(line.amount_incl_vat, 363.0); }
123
124 #[test]
125 fn test_create_line_item_with_vat_6_percent() {
126 let expense_id = Uuid::new_v4();
127 let line = InvoiceLineItem::new(
128 expense_id,
129 "Travaux isolation toit".to_string(),
130 1.0,
131 10000.0,
132 6.0, )
134 .unwrap();
135
136 assert_eq!(line.amount_excl_vat, 10000.0);
137 assert_eq!(line.vat_amount, 600.0); assert_eq!(line.amount_incl_vat, 10600.0);
139 }
140
141 #[test]
142 fn test_create_line_item_empty_description_fails() {
143 let expense_id = Uuid::new_v4();
144 let line = InvoiceLineItem::new(expense_id, " ".to_string(), 1.0, 100.0, 21.0);
145
146 assert!(line.is_err());
147 assert_eq!(line.unwrap_err(), "Description cannot be empty");
148 }
149
150 #[test]
151 fn test_create_line_item_zero_quantity_fails() {
152 let expense_id = Uuid::new_v4();
153 let line = InvoiceLineItem::new(expense_id, "Test".to_string(), 0.0, 100.0, 21.0);
154
155 assert!(line.is_err());
156 assert_eq!(line.unwrap_err(), "Quantity must be greater than 0");
157 }
158
159 #[test]
160 fn test_create_line_item_negative_unit_price_fails() {
161 let expense_id = Uuid::new_v4();
162 let line = InvoiceLineItem::new(expense_id, "Test".to_string(), 1.0, -50.0, 21.0);
163
164 assert!(line.is_err());
165 assert_eq!(line.unwrap_err(), "Unit price cannot be negative");
166 }
167
168 #[test]
169 fn test_create_line_item_invalid_vat_rate_fails() {
170 let expense_id = Uuid::new_v4();
171 let line = InvoiceLineItem::new(expense_id, "Test".to_string(), 1.0, 100.0, 150.0);
172
173 assert!(line.is_err());
174 }
175
176 #[test]
177 fn test_recalculate_after_quantity_change() {
178 let expense_id = Uuid::new_v4();
179 let mut line =
180 InvoiceLineItem::new(expense_id, "Test".to_string(), 1.0, 100.0, 21.0).unwrap();
181
182 assert_eq!(line.amount_excl_vat, 100.0);
183
184 line.quantity = 3.0;
186 line.recalculate().unwrap();
187
188 assert_eq!(line.amount_excl_vat, 300.0); assert_eq!(line.vat_amount, 63.0); assert_eq!(line.amount_incl_vat, 363.0);
191 }
192
193 #[test]
194 fn test_recalculate_after_unit_price_change() {
195 let expense_id = Uuid::new_v4();
196 let mut line =
197 InvoiceLineItem::new(expense_id, "Test".to_string(), 2.0, 100.0, 21.0).unwrap();
198
199 line.unit_price = 200.0;
201 line.recalculate().unwrap();
202
203 assert_eq!(line.amount_excl_vat, 400.0); assert_eq!(line.vat_amount, 84.0); assert_eq!(line.amount_incl_vat, 484.0);
206 }
207
208 #[test]
209 fn test_total_calculations_multiple_lines() {
210 let expense_id = Uuid::new_v4();
211
212 let lines = vec![
213 InvoiceLineItem::new(expense_id, "Item 1".to_string(), 2.0, 100.0, 21.0).unwrap(),
214 InvoiceLineItem::new(expense_id, "Item 2".to_string(), 1.0, 300.0, 21.0).unwrap(),
215 InvoiceLineItem::new(expense_id, "Item 3".to_string(), 3.0, 50.0, 6.0).unwrap(),
216 ];
217
218 let total_excl_vat = InvoiceLineItem::total_excl_vat(&lines);
224 let total_vat = InvoiceLineItem::total_vat(&lines);
225 let total_incl_vat = InvoiceLineItem::total_incl_vat(&lines);
226
227 assert_eq!(total_excl_vat, 650.0);
228 assert_eq!(total_vat, 114.0);
229 assert_eq!(total_incl_vat, 764.0);
230 }
231
232 #[test]
233 fn test_total_calculations_empty_list() {
234 let lines: Vec<InvoiceLineItem> = vec![];
235
236 assert_eq!(InvoiceLineItem::total_excl_vat(&lines), 0.0);
237 assert_eq!(InvoiceLineItem::total_vat(&lines), 0.0);
238 assert_eq!(InvoiceLineItem::total_incl_vat(&lines), 0.0);
239 }
240
241 #[test]
242 fn test_description_trimmed() {
243 let expense_id = Uuid::new_v4();
244 let line = InvoiceLineItem::new(
245 expense_id,
246 " Peinture couloir ".to_string(),
247 1.0,
248 500.0,
249 21.0,
250 )
251 .unwrap();
252
253 assert_eq!(line.description, "Peinture couloir");
254 }
255}