Skip to main content

koprogo_api/domain/entities/
invoice_line_item.rs

1//! Invoice line item entity — monetary fields use `rust_decimal::Decimal`
2//!
3//! Migration story EXP-003 (cf. ADR-0007 `docs/adr/0007-decimal-vs-f64-for-money.md`,
4//! ADR-0008 `docs/adr/0008-numeric-vs-double-precision-postgresql.md`).
5//!
6//! All `f64` monetary fields migrated to `Decimal` for PCMN belge exactness
7//! (Arrêté Royal du 12 juillet 2012). VAT computations no longer subject to
8//! IEEE 754 cumulative drift.
9
10use chrono::{DateTime, Utc};
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Représente une ligne de facture détaillée
17/// Permet de décomposer une facture en plusieurs postes avec quantité et prix unitaire.
18///
19/// **Précision** : tous les montants et taux utilisent `Decimal` pour conformité PCMN.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct InvoiceLineItem {
22    pub id: Uuid,
23    pub expense_id: Uuid, // Référence à la facture parent
24    pub description: String,
25    pub quantity: Decimal,
26    pub unit_price: Decimal, // Prix unitaire HT
27
28    // Montants calculés (exact, conforme PCMN)
29    pub amount_excl_vat: Decimal, // quantity * unit_price
30    pub vat_rate: Decimal,        // Taux TVA (ex: 21.0 pour 21%, ou 6.0)
31    pub vat_amount: Decimal,      // Montant TVA = amount_excl_vat * vat_rate / 100
32    pub amount_incl_vat: Decimal, // Montant TTC = amount_excl_vat + vat_amount
33
34    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        // Validations
46        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        // Calculs automatiques (exact decimal arithmetic, no IEEE 754 drift)
60        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    /// Recalcule les montants si quantity, unit_price ou vat_rate changent.
79    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    /// Calcule le total HT pour toutes les lignes.
97    /// Exact : pas de drift IEEE 754 sur cumul.
98    pub fn total_excl_vat(items: &[InvoiceLineItem]) -> Decimal {
99        items.iter().map(|item| item.amount_excl_vat).sum()
100    }
101
102    /// Calcule le total TVA pour toutes les lignes.
103    pub fn total_vat(items: &[InvoiceLineItem]) -> Decimal {
104        items.iter().map(|item| item.vat_amount).sum()
105    }
106
107    /// Calcule le total TTC pour toutes les lignes.
108    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    // -------------------------------------------------------------------
118    // @happy — chemin nominal end-to-end
119    // -------------------------------------------------------------------
120
121    #[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),   // quantity
128            dec!(150), // unit_price
129            dec!(21),  // vat_rate
130        );
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)); // 2 * 150
138        assert_eq!(line.vat_amount, dec!(63)); // 300 * 21%
139        assert_eq!(line.amount_incl_vat, dec!(363)); // 300 + 63
140    }
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), // TVA réduite 6%
151        )
152        .unwrap();
153
154        assert_eq!(line.amount_excl_vat, dec!(10000));
155        assert_eq!(line.vat_amount, dec!(600)); // 10000 * 6%
156        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        // Item 1: 200 HT, 42 TVA, 242 TTC
185        // Item 2: 300 HT, 63 TVA, 363 TTC
186        // Item 3: 150 HT, 9 TVA, 159 TTC
187        // Total: 650 HT, 114 TVA, 764 TTC
188        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    // -------------------------------------------------------------------
194    // @edge — bornes (max/min/empty/0/1/N, dates limites, precision)
195    // -------------------------------------------------------------------
196
197    #[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        // Le test critique du f64 vs Decimal :
254        // 0.1 + 0.2 == 0.3 EXACTEMENT en Decimal (ce qui est faux en f64).
255        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)); // 100% TVA
305        assert_eq!(line.amount_incl_vat, dec!(200));
306    }
307
308    // -------------------------------------------------------------------
309    // @security — RBAC, auth, injection (n/a entité pure) ; ici on
310    // teste les invariants de saisie qui empêcheraient un client
311    // malveillant de créer une ligne incohérente ou abusive
312    // -------------------------------------------------------------------
313
314    #[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        // Empêche un client malveillant de créer une 'remise' déguisée
326        // en prix négatif qui contournerait les workflows d'approbation.
327        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        // Empêche un taux de TVA aberrant (e.g., 9999%) qui pourrait
337        // gonfler artificiellement le montant TTC.
338        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    // -------------------------------------------------------------------
358    // @negative — défaillance correcte (pas de panic, erreur typée)
359    // -------------------------------------------------------------------
360
361    #[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        // Si on modifie directement les fields invalides puis on
378        // recalcule, l'erreur est retournée proprement (pas de panic).
379        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); // invalide
385        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        // Decimal supports up to ~28 digits. Au-delà, retourne erreur
393        // ou comportement défini (pas de panic IEEE 754 NaN/Inf).
394        let expense_id = Uuid::new_v4();
395        // Valeur grande mais dans la plage Decimal
396        let line = InvoiceLineItem::new(
397            expense_id,
398            "Big".to_string(),
399            dec!(1),
400            dec!(99999999999.99), // proche de la limite NUMERIC(15,2)
401            dec!(21),
402        )
403        .unwrap();
404        // Le calcul doit produire un Decimal valide (pas de NaN)
405        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}