Skip to main content

fatoora_core/invoice/
qr.rs

1//! QR payload generation and encoding.
2use super::{InvoiceData, InvoiceTotalsData};
3use base64ct::{Base64, Encoding};
4use libxml::{tree::Document, xpath};
5use thiserror::Error;
6
7use crate::invoice::xml::constants::{CAC_NS, CBC_NS};
8
9/// Errors emitted when building or encoding QR payloads.
10#[derive(Debug, Error)]
11pub enum QrCodeError {
12    #[error("seller legal name is missing")]
13    MissingSellerName,
14    #[error("seller VAT ID is missing")]
15    MissingSellerVat,
16    #[error("TLV field {tag} exceeds 255 bytes (len={len})")]
17    ValueTooLong { tag: u8, len: usize },
18    #[error("QR code payload exceeds 700 characters once base64 encoded (len={len})")]
19    EncodedTooLong { len: usize },
20    #[error("QR XML error: {0}")]
21    Xml(String),
22}
23
24/// QR result alias.
25pub type QrResult<T> = std::result::Result<T, QrCodeError>;
26
27/// QR payload builder for ZATCA tags.
28///
29/// # Examples
30/// ```rust,ignore
31/// use fatoora_core::invoice::QrPayload;
32/// use fatoora_core::invoice::{FinalizedInvoice, InvoiceTotalsData};
33///
34/// let invoice: FinalizedInvoice = unimplemented!();
35/// let totals = InvoiceTotalsData::from_data(invoice.data());
36/// let payload = QrPayload::from_invoice(invoice.data(), &totals)?;
37/// let encoded = payload.encode()?;
38/// # let _ = encoded;
39/// use fatoora_core::invoice::QrCodeError;
40/// # Ok::<(), QrCodeError>(())
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct QrPayload {
44    seller_name: String,
45    seller_vat: String,
46    timestamp: String,
47    total_with_vat: String,
48    total_vat: String,
49    invoice_hash: Option<String>,
50    signature: Option<String>,
51    public_key: Option<String>,
52    zatca_key_signature: Option<String>,
53}
54
55impl QrPayload {
56    pub(crate) fn from_invoice(
57        invoice: &InvoiceData,
58        totals: &InvoiceTotalsData,
59    ) -> QrResult<Self> {
60        let seller_name = invoice.seller_name()?.to_string();
61        let seller_vat = invoice.seller_vat()?.to_string();
62        let timestamp = format!(
63            "{}T{}",
64            invoice.issue_date_string(),
65            invoice.issue_time_string()
66        );
67        let total_with_vat = InvoiceData::format_amount(totals.tax_inclusive_amount());
68        let total_vat = InvoiceData::format_amount(totals.tax_amount());
69
70        Ok(Self {
71            seller_name,
72            seller_vat,
73            timestamp,
74            total_with_vat,
75            total_vat,
76            invoice_hash: None,
77            signature: None,
78            public_key: None,
79            zatca_key_signature: None,
80        })
81    }
82
83    pub(crate) fn from_xml(doc: &Document) -> QrResult<Self> {
84        let ctx = xpath::Context::new(doc)
85            .map_err(|e| QrCodeError::Xml(format!("XPath context error: {e:?}")))?;
86        ctx.register_namespace("cbc", CBC_NS)
87            .map_err(|e| QrCodeError::Xml(format!("XPath context error: {e:?}")))?;
88        ctx.register_namespace("cac", CAC_NS)
89            .map_err(|e| QrCodeError::Xml(format!("XPath context error: {e:?}")))?;
90
91        let seller_name = xpath_text(
92            &ctx,
93            "//cac:AccountingSupplierParty//cac:PartyLegalEntity/cbc:RegistrationName",
94            "seller name",
95        )?;
96        let seller_vat = xpath_text(
97            &ctx,
98            "//cac:AccountingSupplierParty//cac:PartyTaxScheme//cbc:CompanyID",
99            "seller VAT",
100        )?;
101        let issue_date = xpath_text(&ctx, "//cbc:IssueDate", "issue date")?;
102        let issue_time = xpath_text(&ctx, "//cbc:IssueTime", "issue time")?;
103        let total_with_vat = xpath_text(
104            &ctx,
105            "//cac:LegalMonetaryTotal//cbc:TaxInclusiveAmount",
106            "total with VAT",
107        )?;
108        let total_vat = xpath_text(&ctx, "//cac:TaxTotal/cbc:TaxAmount", "total VAT")?;
109
110        Ok(Self {
111            seller_name,
112            seller_vat,
113            timestamp: format!("{issue_date}T{issue_time}Z"),
114            total_with_vat,
115            total_vat,
116            invoice_hash: None,
117            signature: None,
118            public_key: None,
119            zatca_key_signature: None,
120        })
121    }
122
123    pub(crate) fn with_signing_parts(
124        mut self,
125        invoice_hash: Option<&str>,
126        signature: Option<&str>,
127        public_key: Option<&str>,
128        zatca_key_signature: Option<&str>,
129    ) -> Self {
130        self.invoice_hash = invoice_hash.map(|value| value.to_string());
131        self.signature = signature.map(|value| value.to_string());
132        self.public_key = public_key.map(|value| value.to_string());
133        self.zatca_key_signature = zatca_key_signature.map(|value| value.to_string());
134        self
135    }
136
137    pub(crate) fn encode(&self) -> QrResult<String> {
138        let mut tlv = TlvBuilder::new();
139        tlv.push_str(1, &self.seller_name)?;
140        tlv.push_str(2, &self.seller_vat)?;
141        tlv.push_str(3, &self.timestamp)?;
142        tlv.push_str(4, &self.total_with_vat)?;
143        tlv.push_str(5, &self.total_vat)?;
144
145        if let Some(hash) = self.invoice_hash.as_deref() {
146            tlv.push_bytes(6, hash.as_bytes())?;
147        }
148        if let Some(sig) = self.signature.as_deref() {
149            tlv.push_bytes(7, sig.as_bytes())?;
150        }
151        if let Some(pk) = self.public_key.as_deref() {
152            tlv.push_bytes(8, &base64ct::Base64::decode_vec(pk).unwrap())?;
153        }
154        if let Some(stamp_sig) = self.zatca_key_signature.as_deref() {
155            let _ = tlv.push_bytes(
156                9,
157                &base64ct::Base64::decode_vec(stamp_sig).unwrap_or(vec![]),
158            );
159        }
160
161        tlv.finish()
162    }
163}
164
165struct TlvBuilder {
166    bytes: Vec<u8>,
167}
168
169impl TlvBuilder {
170    fn new() -> Self {
171        Self { bytes: Vec::new() }
172    }
173
174    fn push_str(&mut self, tag: u8, value: &str) -> QrResult<()> {
175        self.push_bytes(tag, value.as_bytes())
176    }
177
178    fn push_bytes(&mut self, tag: u8, value: &[u8]) -> QrResult<()> {
179        if value.len() > u8::MAX as usize {
180            return Err(QrCodeError::ValueTooLong {
181                tag,
182                len: value.len(),
183            });
184        }
185        self.bytes.push(tag);
186        self.bytes.push(value.len() as u8);
187        self.bytes.extend_from_slice(value);
188        Ok(())
189    }
190
191    fn finish(self) -> QrResult<String> {
192        let encoded = Base64::encode_string(&self.bytes);
193        if encoded.len() > 700 {
194            return Err(QrCodeError::EncodedTooLong { len: encoded.len() });
195        }
196        let mut enc_hex = String::new();
197        for byte in encoded.as_bytes() {
198            enc_hex.push_str(&format!("{:02X}", byte));
199        }
200        Ok(encoded)
201    }
202}
203
204fn xpath_text(ctx: &xpath::Context, expr: &str, label: &str) -> QrResult<String> {
205    let nodes = ctx
206        .evaluate(expr)
207        .map_err(|e| QrCodeError::Xml(format!("XPath error for {label}: {e:?}")))?
208        .get_nodes_as_vec();
209    let node = nodes
210        .first()
211        .ok_or_else(|| QrCodeError::Xml(format!("Missing {label} in invoice XML")))?;
212    let value = node.get_content().trim().to_string();
213    if value.is_empty() {
214        return Err(QrCodeError::Xml(format!("Empty {label} in invoice XML")));
215    }
216    Ok(value)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::invoice::sign::SignedProperties;
223    use crate::invoice::xml::ToXml;
224    use crate::invoice::{
225        Address, CountryCode, FinalizedInvoice, InvoiceBuilder, InvoiceSubType, InvoiceType,
226        LineItem, Party, SellerRole, VatCategory,
227    };
228    use base64ct::{Base64, Encoding};
229
230    fn sample_invoice() -> FinalizedInvoice {
231        let seller = Party::<SellerRole>::new(
232            "Acme Inc".into(),
233            Address {
234                country_code: CountryCode::parse("SAU").expect("country code"),
235                city: "Riyadh".into(),
236                street: "King Fahd".into(),
237                additional_street: None,
238                building_number: "1234".into(),
239                additional_number: Some("5678".into()),
240                postal_code: "12222".into(),
241                subdivision: None,
242                district: None,
243            },
244            "301121971500003",
245            None,
246        )
247        .expect("valid seller");
248
249        let line_item = LineItem::new("Item", 1.0, "PCE", 100.0, 15.0, VatCategory::Standard);
250
251        let mut builder = InvoiceBuilder::new(InvoiceType::Tax(InvoiceSubType::Simplified));
252        builder
253            .set_id("INV-1")
254            .set_uuid("uuid-123")
255            .set_issue_datetime("2024-01-01T12:30:00Z")
256            .set_currency("SAR")
257            .set_previous_invoice_hash("hash")
258            .set_invoice_counter(0)
259            .set_seller(seller)
260            .set_payment_means_code("10")
261            .set_vat_category(VatCategory::Standard)
262            .add_line_item(line_item);
263        builder.build().expect("build sample invoice")
264    }
265
266    fn decode_tlv(bytes: &[u8]) -> Vec<(u8, Vec<u8>)> {
267        let mut entries = Vec::new();
268        let mut idx = 0;
269        while idx < bytes.len() {
270            let tag = bytes[idx];
271            let len = bytes[idx + 1] as usize;
272            let start = idx + 2;
273            let end = start + len;
274            entries.push((tag, bytes[start..end].to_vec()));
275            idx = end;
276        }
277        entries
278    }
279
280    #[test]
281    fn qr_code_contains_all_required_tags() {
282        let invoice = sample_invoice();
283        let public_key_bytes = b"public-key";
284        let public_key_b64 = Base64::encode_string(public_key_bytes);
285        let stamp_bytes = b"stamp";
286        let stamp_b64 = Base64::encode_string(stamp_bytes);
287        let signing = SignedProperties::from_qr_parts(
288            "hash==",
289            "signature==",
290            &public_key_b64,
291            Some(&stamp_b64),
292        );
293
294        let signed_xml = invoice.to_xml().expect("serialize invoice");
295        let qr = invoice
296            .sign_with_bundle(signing, signed_xml)
297            .expect("sign invoice")
298            .qr_code()
299            .to_string();
300        assert!(qr.len() < 700);
301
302        let raw = Base64::decode_vec(&qr).expect("base64 decode");
303        let entries = decode_tlv(&raw);
304        let expected = vec![
305            (1, b"Acme Inc".to_vec()),
306            (2, b"301121971500003".to_vec()),
307            (3, b"2024-01-01T12:30:00".to_vec()),
308            (4, b"115.00".to_vec()),
309            (5, b"15.00".to_vec()),
310            (6, b"hash==".to_vec()),
311            (7, b"signature==".to_vec()),
312            (8, public_key_bytes.to_vec()),
313            (9, stamp_bytes.to_vec()),
314        ];
315        assert_eq!(entries, expected);
316    }
317
318    #[test]
319    fn qr_code_errors_on_large_field() {
320        let invoice = sample_invoice();
321        let oversized = "a".repeat(300);
322        let pk_b64 = Base64::encode_string(b"pk");
323        let signing = SignedProperties::from_qr_parts(&oversized, "sig", &pk_b64, None);
324
325        let signed_xml = invoice.to_xml().expect("serialize invoice");
326        match invoice.sign_with_bundle(signing, signed_xml) {
327            Err(QrCodeError::ValueTooLong { tag, .. }) => assert_eq!(tag, 6),
328            other => panic!("expected ValueTooLong error, got {:?}", other),
329        }
330    }
331
332    #[test]
333    fn qr_code_errors_when_payload_too_long() {
334        let invoice = sample_invoice();
335        let long_value = "a".repeat(200);
336        let long_key_bytes = vec![b'k'; 200];
337        let long_key_b64 = Base64::encode_string(&long_key_bytes);
338        let signing = SignedProperties::from_qr_parts(
339            &long_value,
340            &long_value,
341            &long_key_b64,
342            Some(&long_key_b64),
343        );
344
345        let signed_xml = invoice.to_xml().expect("serialize invoice");
346        match invoice.sign_with_bundle(signing, signed_xml) {
347            Err(QrCodeError::EncodedTooLong { .. }) => {}
348            other => panic!("expected EncodedTooLong error, got {:?}", other),
349        }
350    }
351}