Skip to main content

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#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
28#[cfg_attr(feature = "json-schema", schemars(rename = "Rechnungsposition"))]
29#[serde(rename_all = "camelCase")]
30pub struct InvoicePosition {
31    /// BO4E metadata
32    #[serde(flatten)]
33    pub meta: Bo4eMeta,
34
35    /// Sequential number for the invoice position (Positionsnummer)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    #[cfg_attr(feature = "json-schema", schemars(rename = "positionsnummer"))]
38    pub position_number: Option<i32>,
39
40    /// Description of the billed position (Positionstext)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    #[cfg_attr(feature = "json-schema", schemars(rename = "positionstext"))]
43    pub position_text: Option<String>,
44
45    /// Delivery period start (simplified - Lieferungszeitraum)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    #[cfg_attr(feature = "json-schema", schemars(rename = "lieferungszeitraumVon"))]
48    pub delivery_period_start: Option<String>,
49
50    /// Delivery period end (simplified - Lieferungszeitraum)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    #[cfg_attr(feature = "json-schema", schemars(rename = "lieferungszeitraumBis"))]
53    pub delivery_period_end: Option<String>,
54
55    // Note: The following fields would typically reference other COM types
56    // (Menge, Preis, Betrag, Steuerbetrag). Using simplified representations.
57    /// Billed quantity value (simplified - Positionsmenge)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    #[cfg_attr(feature = "json-schema", schemars(rename = "positionsmenge"))]
60    pub quantity_value: Option<f64>,
61
62    /// Unit price value (simplified - Einzelpreis)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    #[cfg_attr(feature = "json-schema", schemars(rename = "einzelpreis"))]
65    pub unit_price_value: Option<f64>,
66
67    /// Total price value (simplified - Gesamtpreis)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    #[cfg_attr(feature = "json-schema", schemars(rename = "gesamtpreis"))]
70    pub total_price_value: Option<f64>,
71
72    /// BDEW article number (Artikelnummer)
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[cfg_attr(feature = "json-schema", schemars(rename = "artikelnummer"))]
75    pub article_number: Option<String>,
76
77    /// Article ID replacing BDEW article number (ArtikelId)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    #[cfg_attr(feature = "json-schema", schemars(rename = "artikelId"))]
80    pub article_id: Option<String>,
81
82    /// Tax amount value (simplified - Steuerbetrag)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    #[cfg_attr(feature = "json-schema", schemars(rename = "steuerbetrag"))]
85    pub tax_amount_value: Option<f64>,
86
87    /// Time unit if price is time-based (Zeiteinheit)
88    #[serde(skip_serializing_if = "Option::is_none")]
89    #[cfg_attr(feature = "json-schema", schemars(rename = "zeiteinheit"))]
90    pub time_unit: Option<Unit>,
91
92    /// Time-based quantity value (simplified - Zeitbezogene Menge)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    #[cfg_attr(feature = "json-schema", schemars(rename = "zeitbezogeneMenge"))]
95    pub time_based_quantity_value: Option<f64>,
96}
97
98impl Bo4eObject for InvoicePosition {
99    fn type_name_german() -> &'static str {
100        "Rechnungsposition"
101    }
102
103    fn type_name_english() -> &'static str {
104        "InvoicePosition"
105    }
106
107    fn meta(&self) -> &Bo4eMeta {
108        &self.meta
109    }
110
111    fn meta_mut(&mut self) -> &mut Bo4eMeta {
112        &mut self.meta
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_invoice_position_default() {
122        let pos = InvoicePosition::default();
123        assert!(pos.position_number.is_none());
124        assert!(pos.position_text.is_none());
125    }
126
127    #[test]
128    fn test_invoice_position_serialize() {
129        let pos = InvoicePosition {
130            position_number: Some(1),
131            position_text: Some("Electricity consumption".to_string()),
132            quantity_value: Some(1500.0),
133            unit_price_value: Some(0.32),
134            total_price_value: Some(480.0),
135            ..Default::default()
136        };
137
138        let json = serde_json::to_string(&pos).unwrap();
139        assert!(json.contains(r#""positionNumber":1"#));
140        assert!(json.contains(r#""positionText":"Electricity consumption""#));
141        assert!(json.contains(r#""quantityValue":1500"#));
142    }
143
144    #[test]
145    fn test_invoice_position_roundtrip() {
146        let pos = InvoicePosition {
147            meta: Bo4eMeta::with_type("Rechnungsposition"),
148            position_number: Some(2),
149            position_text: Some("Network usage fee".to_string()),
150            delivery_period_start: Some("2024-01-01T00:00:00+01:00".to_string()),
151            delivery_period_end: Some("2024-01-31T23:59:59+01:00".to_string()),
152            quantity_value: Some(1500.0),
153            unit_price_value: Some(0.08),
154            total_price_value: Some(120.0),
155            article_number: Some("9900001000013".to_string()),
156            article_id: None,
157            tax_amount_value: Some(22.80),
158            time_unit: None,
159            time_based_quantity_value: None,
160        };
161
162        let json = serde_json::to_string(&pos).unwrap();
163        let parsed: InvoicePosition = serde_json::from_str(&json).unwrap();
164        assert_eq!(pos, parsed);
165    }
166
167    #[test]
168    fn test_bo4e_object_impl() {
169        assert_eq!(InvoicePosition::type_name_german(), "Rechnungsposition");
170        assert_eq!(InvoicePosition::type_name_english(), "InvoicePosition");
171    }
172}