koprogo_api/domain/entities/
invoice_line_item.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Représente une ligne de facture détaillée
6/// Permet de décomposer une facture en plusieurs postes avec quantité et prix unitaire
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct InvoiceLineItem {
9    pub id: Uuid,
10    pub expense_id: Uuid, // Référence à la facture parent
11    pub description: String,
12    pub quantity: f64,
13    pub unit_price: f64, // Prix unitaire HT
14
15    // Montants calculés
16    pub amount_excl_vat: f64, // quantity * unit_price
17    pub vat_rate: f64,        // Taux TVA (ex: 21.0 pour 21%)
18    pub vat_amount: f64,      // Montant TVA
19    pub amount_incl_vat: f64, // Montant TTC
20
21    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        // Validations
33        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        // Calculs automatiques
47        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    /// Recalcule les montants si quantity ou unit_price changent
66    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    /// Calcule le total HT pour toutes les lignes
84    pub fn total_excl_vat(items: &[InvoiceLineItem]) -> f64 {
85        items.iter().map(|item| item.amount_excl_vat).sum()
86    }
87
88    /// Calcule le total TVA pour toutes les lignes
89    pub fn total_vat(items: &[InvoiceLineItem]) -> f64 {
90        items.iter().map(|item| item.vat_amount).sum()
91    }
92
93    /// Calcule le total TTC pour toutes les lignes
94    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,   // quantity
110            150.0, // unit_price
111            21.0,  // vat_rate
112        );
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); // 2 * 150
120        assert_eq!(line.vat_amount, 63.0); // 300 * 21%
121        assert_eq!(line.amount_incl_vat, 363.0); // 300 + 63
122    }
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, // TVA réduite 6%
133        )
134        .unwrap();
135
136        assert_eq!(line.amount_excl_vat, 10000.0);
137        assert_eq!(line.vat_amount, 600.0); // 10000 * 6%
138        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        // Changer la quantité
185        line.quantity = 3.0;
186        line.recalculate().unwrap();
187
188        assert_eq!(line.amount_excl_vat, 300.0); // 3 * 100
189        assert_eq!(line.vat_amount, 63.0); // 300 * 21%
190        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        // Changer le prix unitaire
200        line.unit_price = 200.0;
201        line.recalculate().unwrap();
202
203        assert_eq!(line.amount_excl_vat, 400.0); // 2 * 200
204        assert_eq!(line.vat_amount, 84.0); // 400 * 21%
205        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        // Item 1: 200 HT, 42 TVA, 242 TTC
219        // Item 2: 300 HT, 63 TVA, 363 TTC
220        // Item 3: 150 HT, 9 TVA, 159 TTC
221        // Total: 650 HT, 114 TVA, 764 TTC
222
223        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}