Skip to main content

fiscal_core/
tax_element.rs

1//! Internal building blocks for tax XML element serialisation.
2//!
3//! [`TaxField`] and [`TaxElement`] form an intermediate representation used by
4//! tax computation functions (ICMS, PIS, COFINS, IPI, II, ISSQN) before
5//! rendering to an XML string via [`serialize_tax_element`].  Application code
6//! should not need to use these types directly.
7
8/// A single XML field represented as a `<name>value</name>` pair.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TaxField {
11    /// XML tag name.
12    pub name: String,
13    /// Text content (will be XML-escaped on serialisation).
14    pub value: String,
15}
16
17impl TaxField {
18    /// Create a new XML field with the given tag name and text value.
19    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
20        Self {
21            name: name.into(),
22            value: value.into(),
23        }
24    }
25}
26
27/// Structured representation of a tax XML element.
28#[derive(Debug, Clone)]
29pub struct TaxElement {
30    /// Outer wrapper tag (e.g., "ICMS", "PIS", "IPI"). None = no wrapper.
31    pub outer_tag: Option<String>,
32    /// Fields at the outer level, before the variant (e.g., IPI's cEnq).
33    pub outer_fields: Vec<TaxField>,
34    /// The variant/inner tag (e.g., "ICMS00", "PISAliq", "IPITrib", "II").
35    pub variant_tag: String,
36    /// Fields inside the variant tag.
37    pub fields: Vec<TaxField>,
38}
39
40/// Create an optional field (returns None if value is None)
41pub fn optional_field(name: &str, value: Option<&str>) -> Option<TaxField> {
42    value.map(|v| TaxField::new(name, v))
43}
44
45/// Create a required field (returns Err if value is None).
46///
47/// # Errors
48///
49/// Returns [`FiscalError::MissingRequiredField`] if `value` is `None`.
50pub fn required_field(name: &str, value: Option<&str>) -> Result<TaxField, crate::FiscalError> {
51    match value {
52        Some(v) => Ok(TaxField::new(name, v)),
53        None => Err(crate::FiscalError::MissingRequiredField {
54            field: name.to_string(),
55        }),
56    }
57}
58
59/// Filter None entries from a TaxField option array
60pub fn filter_fields(fields: Vec<Option<TaxField>>) -> Vec<TaxField> {
61    fields.into_iter().flatten().collect()
62}
63
64/// Escape XML special characters in a value
65fn escape_xml_value(s: &str) -> String {
66    let mut result = String::with_capacity(s.len());
67    for ch in s.chars() {
68        match ch {
69            '&' => result.push_str("&amp;"),
70            '<' => result.push_str("&lt;"),
71            '>' => result.push_str("&gt;"),
72            '"' => result.push_str("&quot;"),
73            c => result.push(c),
74        }
75    }
76    result
77}
78
79/// Serialize a TaxField to XML: <name>value</name>
80fn serialize_field(field: &TaxField) -> String {
81    format!(
82        "<{name}>{value}</{name}>",
83        name = field.name,
84        value = escape_xml_value(&field.value)
85    )
86}
87
88/// Serialize a TaxElement to an XML string.
89pub fn serialize_tax_element(element: &TaxElement) -> String {
90    let inner_content: String = element.fields.iter().map(serialize_field).collect();
91    let variant_xml = format!("<{tag}>{inner_content}</{tag}>", tag = element.variant_tag,);
92
93    match &element.outer_tag {
94        None => variant_xml,
95        Some(outer) => {
96            let outer_fields_xml: String =
97                element.outer_fields.iter().map(serialize_field).collect();
98            format!("<{outer}>{outer_fields_xml}{variant_xml}</{outer}>")
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn escape_xml_value_with_ampersand_and_quotes() {
109        let result = escape_xml_value("Tom & Jerry \"cats\" <dogs>");
110        assert_eq!(result, "Tom &amp; Jerry &quot;cats&quot; &lt;dogs&gt;");
111    }
112
113    #[test]
114    fn serialize_tax_element_no_outer_tag() {
115        let element = TaxElement {
116            outer_tag: None,
117            outer_fields: vec![],
118            variant_tag: "II".to_string(),
119            fields: vec![
120                TaxField::new("vBC", "100.00"),
121                TaxField::new("vII", "10.00"),
122            ],
123        };
124        let xml = serialize_tax_element(&element);
125        assert_eq!(xml, "<II><vBC>100.00</vBC><vII>10.00</vII></II>");
126    }
127
128    #[test]
129    fn serialize_tax_element_with_outer_tag() {
130        let element = TaxElement {
131            outer_tag: Some("IPI".to_string()),
132            outer_fields: vec![TaxField::new("cEnq", "999")],
133            variant_tag: "IPINT".to_string(),
134            fields: vec![TaxField::new("CST", "53")],
135        };
136        let xml = serialize_tax_element(&element);
137        assert!(xml.starts_with("<IPI>"));
138        assert!(xml.contains("<cEnq>999</cEnq>"));
139        assert!(xml.contains("<IPINT><CST>53</CST></IPINT>"));
140        assert!(xml.ends_with("</IPI>"));
141    }
142}