bo4e_core/com/
invoice_position.rs

1//! Invoice position (Rechnungsposition) component.
2
3use serde::{Deserialize, Serialize};
4
5use crate::enums::Unit;
6use crate::traits::{Bo4eMeta, Bo4eObject};
7
8/// Position within an invoice.
9///
10/// Invoices are structured through invoice positions. Each invoice part
11/// bills a self-contained service.
12///
13/// German: Rechnungsposition
14///
15/// # Example
16///
17/// ```rust
18/// use bo4e_core::com::InvoicePosition;
19///
20/// let position = InvoicePosition {
21///     position_number: Some(1),
22///     position_text: Some("Electricity delivery January 2024".to_string()),
23///     ..Default::default()
24/// };
25/// ```
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct InvoicePosition {
29    /// BO4E metadata
30    #[serde(flatten)]
31    pub meta: Bo4eMeta,
32
33    /// Sequential number for the invoice position (Positionsnummer)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub position_number: Option<i32>,
36
37    /// Description of the billed position (Positionstext)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub position_text: Option<String>,
40
41    /// Delivery period start (simplified - Lieferungszeitraum)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub delivery_period_start: Option<String>,
44
45    /// Delivery period end (simplified - Lieferungszeitraum)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub delivery_period_end: Option<String>,
48
49    // Note: The following fields would typically reference other COM types
50    // (Menge, Preis, Betrag, Steuerbetrag). Using simplified representations.
51    /// Billed quantity value (simplified - Positionsmenge)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub quantity_value: Option<f64>,
54
55    /// Unit price value (simplified - Einzelpreis)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub unit_price_value: Option<f64>,
58
59    /// Total price value (simplified - Gesamtpreis)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub total_price_value: Option<f64>,
62
63    /// BDEW article number (Artikelnummer)
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub article_number: Option<String>,
66
67    /// Article ID replacing BDEW article number (ArtikelId)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub article_id: Option<String>,
70
71    /// Tax amount value (simplified - Steuerbetrag)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub tax_amount_value: Option<f64>,
74
75    /// Time unit if price is time-based (Zeiteinheit)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub time_unit: Option<Unit>,
78
79    /// Time-based quantity value (simplified - Zeitbezogene Menge)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub time_based_quantity_value: Option<f64>,
82}
83
84impl Bo4eObject for InvoicePosition {
85    fn type_name_german() -> &'static str {
86        "Rechnungsposition"
87    }
88
89    fn type_name_english() -> &'static str {
90        "InvoicePosition"
91    }
92
93    fn meta(&self) -> &Bo4eMeta {
94        &self.meta
95    }
96
97    fn meta_mut(&mut self) -> &mut Bo4eMeta {
98        &mut self.meta
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_invoice_position_default() {
108        let pos = InvoicePosition::default();
109        assert!(pos.position_number.is_none());
110        assert!(pos.position_text.is_none());
111    }
112
113    #[test]
114    fn test_invoice_position_serialize() {
115        let pos = InvoicePosition {
116            position_number: Some(1),
117            position_text: Some("Electricity consumption".to_string()),
118            quantity_value: Some(1500.0),
119            unit_price_value: Some(0.32),
120            total_price_value: Some(480.0),
121            ..Default::default()
122        };
123
124        let json = serde_json::to_string(&pos).unwrap();
125        assert!(json.contains(r#""positionNumber":1"#));
126        assert!(json.contains(r#""positionText":"Electricity consumption""#));
127        assert!(json.contains(r#""quantityValue":1500"#));
128    }
129
130    #[test]
131    fn test_invoice_position_roundtrip() {
132        let pos = InvoicePosition {
133            meta: Bo4eMeta::with_type("Rechnungsposition"),
134            position_number: Some(2),
135            position_text: Some("Network usage fee".to_string()),
136            delivery_period_start: Some("2024-01-01T00:00:00+01:00".to_string()),
137            delivery_period_end: Some("2024-01-31T23:59:59+01:00".to_string()),
138            quantity_value: Some(1500.0),
139            unit_price_value: Some(0.08),
140            total_price_value: Some(120.0),
141            article_number: Some("9900001000013".to_string()),
142            article_id: None,
143            tax_amount_value: Some(22.80),
144            time_unit: None,
145            time_based_quantity_value: None,
146        };
147
148        let json = serde_json::to_string(&pos).unwrap();
149        let parsed: InvoicePosition = serde_json::from_str(&json).unwrap();
150        assert_eq!(pos, parsed);
151    }
152
153    #[test]
154    fn test_bo4e_object_impl() {
155        assert_eq!(InvoicePosition::type_name_german(), "Rechnungsposition");
156        assert_eq!(InvoicePosition::type_name_english(), "InvoicePosition");
157    }
158}