Skip to main content

fatoora_core/
api.rs

1//! ZATCA HTTP API client and response types.
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use crate::{
6    config::{Config, EnvironmentType},
7    csr::Csr,
8    invoice::SignedInvoice,
9};
10use std::marker::PhantomData;
11
12/// Errors returned by the ZATCA API client.
13#[derive(Error, Debug)]
14pub enum ZatcaError {
15    #[error("Network error: {0}")]
16    NetworkError(String),
17    #[error("Invalid response from ZATCA: {0}")]
18    InvalidResponse(String),
19    #[error("Unauthorized: {0:?}")]
20    Unauthorized(UnauthorizedResponse),
21    #[error("Server error: {0:?}")]
22    ServerError(ServerErrorResponse),
23    #[error("HTTP client error: {0}")]
24    Http(String),
25    #[error("Client state error: {0}")]
26    ClientState(String),
27}
28
29/// Marker trait for API token scope, either Compliance (CCSID) or Production (PCSID).
30pub trait TokenScope {}
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32/// Compliance (CCSID) token scope.
33pub struct Compliance;
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35/// Production (PCSID) token scope.
36pub struct Production;
37impl TokenScope for Compliance {}
38impl TokenScope for Production {}
39
40/// ZATCA API client.
41///
42/// # Examples
43/// ```rust,no_run
44/// use fatoora_core::api::ZatcaClient;
45/// use fatoora_core::config::Config;
46///
47/// let client = ZatcaClient::new(Config::default())?;
48/// # let _ = client;
49/// use fatoora_core::api::ZatcaError;
50/// # Ok::<(), ZatcaError>(())
51/// ```
52#[derive(Debug)]
53pub struct ZatcaClient {
54    config: Config,
55    _client: Client,
56    base_url: String,
57}
58
59/// API validation response.
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub struct ValidationResponse {
62    #[serde(rename = "validationResults")]
63    validation_results: ValidationResults,
64    #[serde(rename = "reportingStatus")]
65    reporting_status: Option<String>,
66    #[serde(rename = "clearanceStatus")]
67    clearance_status: Option<String>,
68    #[serde(rename = "qrSellertStatus")]
69    qr_seller_status: Option<String>,
70    #[serde(rename = "qrBuyertStatus")]
71    qr_buyer_status: Option<String>,
72}
73
74impl ValidationResponse {
75    pub fn validation_results(&self) -> &ValidationResults {
76        &self.validation_results
77    }
78
79    pub fn reporting_status(&self) -> Option<&str> {
80        self.reporting_status.as_deref()
81    }
82
83    pub fn clearance_status(&self) -> Option<&str> {
84        self.clearance_status.as_deref()
85    }
86
87    pub fn qr_seller_status(&self) -> Option<&str> {
88        self.qr_seller_status.as_deref()
89    }
90
91    pub fn qr_buyer_status(&self) -> Option<&str> {
92        self.qr_buyer_status.as_deref()
93    }
94}
95
96/// Validation results container.
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
98pub struct ValidationResults {
99    #[serde(rename = "infoMessages", default)]
100    info_messages: MessageList,
101    #[serde(rename = "warningMessages", default)]
102    warning_messages: Vec<ValidationMessage>,
103    #[serde(rename = "errorMessages", default)]
104    error_messages: Vec<ValidationMessage>,
105    #[serde(default)]
106    status: Option<String>,
107}
108
109impl ValidationResults {
110    pub fn info_messages(&self) -> &MessageList {
111        &self.info_messages
112    }
113
114    pub fn warning_messages(&self) -> &[ValidationMessage] {
115        &self.warning_messages
116    }
117
118    pub fn error_messages(&self) -> &[ValidationMessage] {
119        &self.error_messages
120    }
121
122    pub fn status(&self) -> Option<&str> {
123        self.status.as_deref()
124    }
125}
126
127/// Validation message.
128#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub struct ValidationMessage {
130    #[serde(rename = "type")]
131    message_type: Option<String>,
132    code: Option<String>,
133    category: Option<String>,
134    message: Option<String>,
135    #[serde(default)]
136    status: Option<String>,
137}
138
139impl ValidationMessage {
140    pub fn message_type(&self) -> Option<&str> {
141        self.message_type.as_deref()
142    }
143
144    pub fn code(&self) -> Option<&str> {
145        self.code.as_deref()
146    }
147
148    pub fn category(&self) -> Option<&str> {
149        self.category.as_deref()
150    }
151
152    pub fn message(&self) -> Option<&str> {
153        self.message.as_deref()
154    }
155
156    pub fn status(&self) -> Option<&str> {
157        self.status.as_deref()
158    }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(untagged)]
163#[derive(Default)]
164/// Message list returned by the API.
165pub enum MessageList {
166    One(ValidationMessage),
167    Many(Vec<ValidationMessage>),
168    #[default]
169    Empty,
170}
171
172/// Unauthorized response body.
173#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
174pub struct UnauthorizedResponse {
175    timestamp: Option<i64>,
176    status: Option<u16>,
177    error: Option<String>,
178    message: Option<String>,
179}
180
181impl UnauthorizedResponse {
182    pub fn timestamp(&self) -> Option<i64> {
183        self.timestamp
184    }
185
186    pub fn status(&self) -> Option<u16> {
187        self.status
188    }
189
190    pub fn error(&self) -> Option<&str> {
191        self.error.as_deref()
192    }
193
194    pub fn message(&self) -> Option<&str> {
195        self.message.as_deref()
196    }
197}
198
199/// Server error response body.
200#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
201pub struct ServerErrorResponse {
202    category: Option<String>,
203    code: Option<String>,
204    message: Option<String>,
205}
206
207impl ServerErrorResponse {
208    pub fn category(&self) -> Option<&str> {
209        self.category.as_deref()
210    }
211
212    pub fn code(&self) -> Option<&str> {
213        self.code.as_deref()
214    }
215
216    pub fn message(&self) -> Option<&str> {
217        self.message.as_deref()
218    }
219}
220
221
222/// CSID credentials used for API calls.
223/// This is usually obtained after requesting a CSID from ZATCA.
224/// ie. through [post_ccsid_for_pcsid][ZatcaClient::post_ccsid_for_pcsid] or [post_csr_for_ccsid][ZatcaClient::post_csr_for_ccsid].
225/// But can also be constructed manually if you have the necessary values.
226///
227/// # Examples
228/// ```rust
229/// use fatoora_core::api::{CsidCredentials, Compliance};
230/// use fatoora_core::config::EnvironmentType;
231///
232/// let creds = CsidCredentials::<Compliance>::new(
233///     EnvironmentType::NonProduction,
234///     Some("1234567890123".to_string()), // requestID field
235///     "TUlJQ1BUQ0NBZU9nQXdJQkFnS....",   // binarySecurityToken field
236///     "Dehvg1fc8GF6Jwt5bOxXwC6en....",   // secret field 
237/// );
238/// ```
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub struct CsidCredentials<T> {
241    env: EnvironmentType,
242    request_id: Option<String>,
243    binary_security_token: String,
244    secret: String,
245    #[serde(skip)]
246    _marker: PhantomData<T>,
247}
248
249impl<T> CsidCredentials<T> {
250    /// Create credential bundle for ZATCA requests.
251    pub fn new(
252        env: EnvironmentType,
253        request_id: Option<String>,
254        binary_security_token: impl Into<String>,
255        secret: impl Into<String>,
256    ) -> Self {
257        Self {
258            env,
259            request_id,
260            binary_security_token: binary_security_token.into(),
261            secret: secret.into(),
262            _marker: PhantomData,
263        }
264    }
265
266    pub fn env(&self) -> EnvironmentType {
267        self.env
268    }
269
270    pub fn request_id(&self) -> Option<&str> {
271        self.request_id.as_deref()
272    }
273
274    pub fn binary_security_token(&self) -> &str {
275        &self.binary_security_token
276    }
277
278    pub fn secret(&self) -> &str {
279        &self.secret
280    }
281}
282
283#[derive(Debug, Deserialize)]
284struct CsidResponseBody {
285    #[serde(rename = "requestID")]
286    #[serde(deserialize_with = "deserialize_request_id")]
287    request_id: Option<String>,
288    #[serde(rename = "binarySecurityToken")]
289    binary_security_token: String,
290    secret: String,
291    #[allow(dead_code)]
292    #[serde(rename = "tokenType")]
293    token_type: Option<String>,
294    #[allow(dead_code)]
295    #[serde(rename = "dispositionMessage")]
296    disposition_message: Option<String>,
297}
298
299fn deserialize_request_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
300where
301    D: serde::Deserializer<'de>,
302{
303    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
304    match value {
305        None => Ok(None),
306        Some(serde_json::Value::String(value)) => Ok(Some(value)),
307        Some(serde_json::Value::Number(value)) => Ok(Some(value.to_string())),
308        Some(other) => Err(serde::de::Error::custom(format!(
309            "invalid requestID type: {other}"
310        ))),
311    }
312}
313
314#[derive(Debug, Deserialize)]
315#[serde(untagged)]
316enum RenewalResponseBody {
317    Direct(CsidResponseBody),
318    Wrapped { value: CsidResponseBody },
319}
320
321// Public API
322impl ZatcaClient {
323    /// Create a new API client using the provided configuration.
324    ///
325    /// # Errors
326    /// Returns [`ZatcaError::Http`] if the HTTP client cannot be built.
327    pub fn new(config: Config) -> Result<Self, ZatcaError> {
328        let client = Client::builder()
329            .build()
330            .map_err(|e| ZatcaError::Http(e.to_string()))?;
331        let base_url = std::env::var("FATOORA_ZATCA_BASE_URL")
332            .ok()
333            .map(|value| {
334                if value.ends_with('/') {
335                    value
336                } else {
337                    format!("{value}/")
338                }
339            })
340            .unwrap_or_else(|| config.env().endpoint_url().to_string());
341
342        Ok(Self {
343            config,
344            _client: client,
345            base_url,
346        })
347    }
348
349    /// Report a simplified invoice to ZATCA's gateway.
350    /// See [ZATCA documentation](https://sandbox.zatca.gov.sa/IntegrationSandbox/reporting-api) for more details.
351    ///
352    /// # Errors
353    /// Returns [`ZatcaError`] for network failures, invalid responses, or client state issues.
354    pub async fn report_simplified_invoice(
355        &self,
356        invoice: &SignedInvoice,
357        credentials: &CsidCredentials<Production>,
358        clearance_status: bool,
359        accept_language: Option<&str>,
360    ) -> Result<ValidationResponse, ZatcaError> {
361        self.ensure_env(credentials)?;
362        if !invoice.data().invoice_type().is_simplified() {
363            return Err(ZatcaError::ClientState(
364                "Reporting only supports simplified invoices".into(),
365            ));
366        }
367
368        let payload = serde_json::json!({
369            "invoiceHash": invoice.invoice_hash(),
370            "uuid": invoice.uuid(),
371            "invoice": invoice.to_xml_base64()
372        });
373        let url = self.build_endpoint("invoices/reporting/single");
374        let mut request = self
375            ._client
376            .post(url)
377            .header("Accept", "application/json")
378            .header("Accept-Version", "V2")
379            .header("Content-Type", "application/json")
380            .header("Clearance-Status", if clearance_status { "1" } else { "0" })
381            .basic_auth(
382                credentials.binary_security_token().to_string(),
383                Some(credentials.secret().to_string()),
384            )
385            .json(&payload);
386
387        match accept_language {
388            Some("ar") => request = request.header("accept-language", "ar"),
389            _ => request = request.header("accept-language", "en")
390        }
391
392        let response = request
393            .send()
394            .await
395            .map_err(|e| ZatcaError::Http(e.to_string()))?;
396        let status = response.status();
397        let body = response.text().await.unwrap_or_default();
398
399        if status.is_success() || status.as_u16() == 400 || status.as_u16() == 409 {
400            match serde_json::from_str::<ValidationResponse>(&body) {
401                Ok(parsed) => return Ok(parsed),
402                Err(_) => {
403                    return Err(ZatcaError::InvalidResponse(format!(
404                        "status {status}: {body}"
405                    )))
406                }
407            }
408        }
409
410        if status.as_u16() == 406 {
411            return Err(ZatcaError::InvalidResponse(format!(
412                "status {status}: {body}"
413            )));
414        }
415
416        if status.as_u16() == 401 {
417            let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
418                UnauthorizedResponse {
419                    timestamp: None,
420                    status: Some(401),
421                    error: Some("Unauthorized".into()),
422                    message: Some(body.clone()),
423                }
424            });
425            return Err(ZatcaError::Unauthorized(parsed));
426        }
427
428        if status.is_server_error() {
429            let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
430                ServerErrorResponse {
431                    category: None,
432                    code: Some("ServerError".into()),
433                    message: Some(body.clone()),
434                }
435            });
436            return Err(ZatcaError::ServerError(parsed));
437        }
438
439        Err(ZatcaError::InvalidResponse(format!(
440            "status {status}: {body}"
441        )))
442    }
443
444    /// Clear a standard invoice through ZATCA's gateway.
445    /// See [ZATCA documentation](https://sandbox.zatca.gov.sa/Integration/clearance-api) for more details.
446    ///
447    /// # Errors
448    /// Returns [`ZatcaError`] for network failures, invalid responses, or client state issues.
449    pub async fn clear_standard_invoice(
450        &self,
451        invoice: &SignedInvoice,
452        credentials: &CsidCredentials<Production>,
453        clearance_status: bool,
454        accept_language: Option<&str>,
455    ) -> Result<ValidationResponse, ZatcaError> {
456        self.ensure_env(credentials)?;
457        if invoice.data().invoice_type().is_simplified() {
458            return Err(ZatcaError::ClientState(
459                "Clearance only supports standard invoices".into(),
460            ));
461        }
462
463        let payload = serde_json::json!({
464            "invoiceHash": invoice.invoice_hash(),
465            "uuid": invoice.uuid(),
466            "invoice": invoice.to_xml_base64()
467        });
468        let url = self.build_endpoint("invoices/clearance/single");
469        let mut request = self
470            ._client
471            .post(url)
472            .header("Accept", "application/json")
473            .header("Accept-Version", "V2")
474            .header("Content-Type", "application/json")
475            .header("Clearance-Status", if clearance_status { "1" } else { "0" })
476            .basic_auth(
477                credentials.binary_security_token().to_string(),
478                Some(credentials.secret().to_string()),
479            )
480            .json(&payload);
481
482        match accept_language {
483            Some("ar") => request = request.header("accept-language", "ar"),
484            _ => request = request.header("accept-language", "en"),
485        }
486
487        let response = request
488            .send()
489            .await
490            .map_err(|e| ZatcaError::Http(e.to_string()))?;
491        let status = response.status();
492        let body = response.text().await.unwrap_or_default();
493
494        if status.is_success() || status.as_u16() == 400 {
495            match serde_json::from_str::<ValidationResponse>(&body) {
496                Ok(parsed) => return Ok(parsed),
497                Err(_) => {
498                    return Err(ZatcaError::InvalidResponse(format!(
499                        "status {status}: {body}"
500                    )))
501                }
502            }
503        }
504
505        if status.as_u16() == 401 {
506            let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
507                UnauthorizedResponse {
508                    timestamp: None,
509                    status: Some(401),
510                    error: Some("Unauthorized".into()),
511                    message: Some(body.clone()),
512                }
513            });
514            return Err(ZatcaError::Unauthorized(parsed));
515        }
516
517        if status.is_server_error() {
518            let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
519                ServerErrorResponse {
520                    category: None,
521                    code: Some("ServerError".into()),
522                    message: Some(body.clone()),
523                }
524            });
525            return Err(ZatcaError::ServerError(parsed));
526        }
527
528        Err(ZatcaError::InvalidResponse(format!(
529            "status {status}: {body}"
530        )))
531    }
532
533    /// Check invoice compliance through ZATCA's gateway.
534    /// See [ZATCA documentation](https://sandbox.zatca.gov.sa/IntegrationSandbox/preInvoice-api) for more details.
535    ///
536    /// # Errors
537    /// Returns [`ZatcaError`] for network failures, invalid responses, or client state issues.
538    pub async fn check_invoice_compliance(
539        &self,
540        invoice: &SignedInvoice,
541        credentials: &CsidCredentials<Compliance>,
542    ) -> Result<ValidationResponse, ZatcaError> {
543        self.ensure_env(credentials)?;
544        let payload = serde_json::json!({
545            "invoiceHash": invoice.invoice_hash(),
546            "uuid": invoice.uuid(),
547            "invoice": invoice.to_xml_base64()
548        });
549
550        let url = self.build_endpoint("compliance/invoices");
551
552        let response = self
553            ._client
554            .post(url)
555            .header("Accept", "application/json")
556            .header("Accept-Language", "en")
557            .header("Accept-Version", "V2")
558            .header("Content-Type", "application/json")
559            .basic_auth(
560                credentials.binary_security_token().to_string(),
561                Some(credentials.secret().to_string()),
562            )
563            .json(&payload)
564            .send()
565            .await
566            .map_err(|e| ZatcaError::Http(e.to_string()))?;
567
568        let status = response.status();
569        let body = response.text().await.unwrap_or_default();
570
571        if status.is_success() || status.as_u16() == 400 {
572            let parsed = serde_json::from_str::<ValidationResponse>(&body)
573                .map_err(|e| ZatcaError::InvalidResponse(format!("Invalid response: {e:?}")))?;
574            return Ok(parsed);
575        }
576
577        if status.as_u16() == 401 {
578            let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
579                UnauthorizedResponse {
580                    timestamp: None,
581                    status: Some(401),
582                    error: Some("Unauthorized".into()),
583                    message: Some(body.clone()),
584                }
585            });
586            return Err(ZatcaError::Unauthorized(parsed));
587        }
588
589        if status.is_server_error() {
590            let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
591                ServerErrorResponse {
592                    category: None,
593                    code: Some("ServerError".into()),
594                    message: Some(body.clone()),
595                }
596            });
597            return Err(ZatcaError::ServerError(parsed));
598        }
599
600        Err(ZatcaError::InvalidResponse(format!(
601            "status {status}: {body}"
602        )))
603    }
604    /// Request a compliance CSID from ZATCA by submitting a CSR.
605    /// See [ZATCA
606    /// documentation](https://sandbox.zatca.gov.sa/IntegrationSandbox/complianceCert-api) for more details.
607    ///
608    /// # Errors
609    /// Returns [`ZatcaError`] if the request fails or the response cannot be parsed.
610    pub async fn post_csr_for_ccsid(
611        &self,
612        csr: &Csr,
613        otp: &str,
614    ) -> Result<CsidCredentials<Compliance>, ZatcaError> {
615        let encoded_csr = csr
616            .to_pem_base64()
617            .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
618        let csr_payload = serde_json::json!({ "csr": encoded_csr });
619        let url = self.build_endpoint("compliance");
620        let response = self
621            ._client
622            .post(url)
623            .header("Accept", "application/json")
624            .header("OTP", otp)
625            .header("Accept-Version", "V2")
626            .header("Content-Type", "application/json")
627            .json(&csr_payload)
628            .send()
629            .await
630            .map_err(|e| ZatcaError::Http(e.to_string()))?;
631        let status = response.status();
632
633        if !status.is_success() {
634            let message = response.text().await.unwrap_or_default();
635            return Err(ZatcaError::InvalidResponse(format!(
636                "status {status}: {message}"
637            )));
638        }
639
640        let payload: CsidResponseBody = response
641            .json()
642            .await
643            .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
644        Ok(CsidCredentials::new(
645            self.config.env(),
646            payload.request_id,
647            payload.binary_security_token,
648            payload.secret,
649        ))
650    }
651
652    /// Requests a production CSID from ZATCA using a compliance CSID previously obtained e.g. from [post_csr_for_ccsid][ZatcaClient::post_csr_for_ccsid].
653    /// See [ZATCA documentation](https://sandbox.zatca.gov.sa/Integration/request-api) for more details.
654    ///
655    /// # Errors
656    /// Returns [`ZatcaError`] if the request fails or the compliance CSID is missing data.
657    pub async fn post_ccsid_for_pcsid(
658        &self,
659        ccsid: &CsidCredentials<Compliance>,
660    ) -> Result<CsidCredentials<Production>, ZatcaError> {
661        self.ensure_env(ccsid)?;
662        let request_id = ccsid
663            .request_id()
664            .ok_or_else(|| ZatcaError::ClientState("Missing compliance request_id".into()))?;
665        let payload = serde_json::json!({
666            "compliance_request_id": request_id,
667        });
668
669        let url = self.build_endpoint("production/csids");
670        let response = self
671            ._client
672            .post(url)
673            .header("Accept", "application/json")
674            .header("Accept-Version", "V2")
675            .header("Content-Type", "application/json")
676            .basic_auth(
677                ccsid.binary_security_token().to_string(),
678                Some(ccsid.secret().to_string()),
679            )
680            .json(&payload)
681            .send()
682            .await
683            .map_err(|e| ZatcaError::Http(e.to_string()))?;
684        let status = response.status();
685
686        if !status.is_success() {
687            let message = response.text().await.unwrap_or_default();
688            return Err(ZatcaError::InvalidResponse(format!(
689                "status {status}: {message}"
690            )));
691        }
692
693        let payload: CsidResponseBody = response
694            .json()
695            .await
696            .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
697
698        Ok(CsidCredentials::new(
699            self.config.env(),
700            payload.request_id,
701            payload.binary_security_token,
702            payload.secret,
703        ))
704    }
705
706    /// Renew a production CSID by submitting a new CSR.
707    /// See [ZATCA documentation](https://sandbox.zatca.gov.sa/Integration/renewal-api) for more details.
708    ///
709    /// # Errors
710    /// Returns [`ZatcaError`] if the request fails or the response cannot be parsed.
711    pub async fn renew_csid(
712        &self,
713        pcsid: &CsidCredentials<Production>,
714        csr: &Csr,
715        otp: &str,
716        accept_language: Option<&str>,
717    ) -> Result<CsidCredentials<Production>, ZatcaError> {
718        self.ensure_env(pcsid)?;
719        let encoded_csr = csr
720            .to_pem_base64()
721            .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
722        let csr_payload = serde_json::json!({ "csr": encoded_csr });
723        let url = self.build_endpoint("production/csids");
724        let mut request = self
725            ._client
726            .patch(url)
727            .header("Accept", "application/json")
728            .header("OTP", otp)
729            .header("Accept-Version", "V2")
730            .header("Content-Type", "application/json")
731            .basic_auth(
732                pcsid.binary_security_token().to_string(),
733                Some(pcsid.secret().to_string()),
734            )
735            .json(&csr_payload);
736
737        match accept_language {
738            Some("ar") => request = request.header("accept-language", "ar"),
739            _ => request = request.header("accept-language", "en"),
740        }
741
742        let response = request
743            .send()
744            .await
745            .map_err(|e| ZatcaError::Http(e.to_string()))?;
746        let status = response.status();
747        let body = response.text().await.unwrap_or_default();
748
749        if status.is_success() || status.as_u16() == 428 {
750            let parsed: RenewalResponseBody = serde_json::from_str(&body)
751                .map_err(|e| ZatcaError::InvalidResponse(format!("Invalid response: {e:?}")))?;
752            let payload = match parsed {
753                RenewalResponseBody::Direct(value) => value,
754                RenewalResponseBody::Wrapped { value } => value,
755            };
756            return Ok(CsidCredentials::new(
757                self.config.env(),
758                payload.request_id,
759                payload.binary_security_token,
760                payload.secret,
761            ));
762        }
763
764        if status.as_u16() == 401 {
765            let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
766                UnauthorizedResponse {
767                    timestamp: None,
768                    status: Some(401),
769                    error: Some("Unauthorized".into()),
770                    message: Some(body.clone()),
771                }
772            });
773            return Err(ZatcaError::Unauthorized(parsed));
774        }
775
776        if status.is_server_error() {
777            let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
778                ServerErrorResponse {
779                    category: None,
780                    code: Some("ServerError".into()),
781                    message: Some(body.clone()),
782                }
783            });
784            return Err(ZatcaError::ServerError(parsed));
785        }
786
787        Err(ZatcaError::InvalidResponse(format!(
788            "status {status}: {body}"
789        )))
790    }
791}
792
793// Private API
794impl ZatcaClient {
795    fn build_endpoint(&self, path: &str) -> String {
796        format!(
797            "{}{}",
798            self.base_url,
799            path.trim_start_matches('/')
800        )
801    }
802
803    fn ensure_env<T>(&self, creds: &CsidCredentials<T>) -> Result<(), ZatcaError> {
804        if creds.env() != self.config.env() {
805            return Err(ZatcaError::ClientState("CSID environment mismatch".into()));
806        }
807        Ok(())
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::{
815        csr::CsrProperties,
816        invoice::{
817            sign::SignedProperties, xml::ToXml, Address, CountryCode, InvoiceBuilder,
818            InvoiceSubType, InvoiceType, LineItem, Party, SellerRole, VatCategory,
819        },
820    };
821    use base64ct::{Base64, Encoding};
822    use httpmock::{Method::PATCH, Method::POST, MockServer};
823    use std::path::Path;
824
825    #[test]
826    fn deserialize_validation_response_with_info_object() {
827        let payload = r#"{
828          "validationResults": {
829            "infoMessages": {
830              "type": "INFO",
831              "code": "XSD_ZATCA_VALID",
832              "category": "XSD validation",
833              "message": "Complied with UBL 2.1 standards in line with ZATCA specifications",
834              "status": "PASS"
835            }
836          },
837          "warningMessages": [],
838          "errorMessages": [],
839          "status": "PASS",
840          "reportingStatus": "REPORTED",
841          "clearanceStatus": null,
842          "qrSellertStatus": null,
843          "qrBuyertStatus": null
844        }"#;
845
846        let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
847        match parsed.validation_results.info_messages {
848            MessageList::One(msg) => assert_eq!(msg.code.as_deref(), Some("XSD_ZATCA_VALID")),
849            other => panic!("expected single info message, got {:?}", other),
850        }
851    }
852
853    #[test]
854    fn deserialize_validation_response_with_info_array() {
855        let payload = r#"{
856          "validationResults": {
857            "infoMessages": [
858              {
859                "type": "INFO",
860                "code": "XSD_ZATCA_VALID",
861                "category": "XSD validation",
862                "message": "Complied with UBL 2.1 standards in line with ZATCA specifications",
863                "status": "PASS"
864              }
865            ],
866            "warningMessages": [],
867            "errorMessages": [
868              {
869                "type": "ERROR",
870                "code": "BR-KSA-37",
871                "category": "KSA",
872                "message": "The seller address building number must contain 4 digits.",
873                "status": "ERROR"
874              }
875            ],
876            "status": "ERROR"
877          },
878          "reportingStatus": "NOT_REPORTED",
879          "clearanceStatus": null,
880          "qrSellertStatus": null,
881          "qrBuyertStatus": null
882        }"#;
883
884        let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
885        match parsed.validation_results.info_messages {
886            MessageList::Many(list) => assert_eq!(list.len(), 1),
887            other => panic!("expected info list, got {:?}", other),
888        }
889        assert_eq!(parsed.validation_results.error_messages.len(), 1);
890    }
891
892    #[test]
893    fn deserialize_renewal_response_wrapped() {
894        let payload = r#"{
895          "value": {
896            "requestID": 1234567890,
897            "tokenType": null,
898            "dispositionMessage": "NOT_COMPLIANT",
899            "binarySecurityToken": "token",
900            "secret": "secret",
901            "errors": null
902          }
903        }"#;
904
905        let parsed: RenewalResponseBody = serde_json::from_str(payload).expect("deserialize");
906        let value = match parsed {
907            RenewalResponseBody::Wrapped { value } => value,
908            RenewalResponseBody::Direct(value) => value,
909        };
910        assert_eq!(value.request_id.as_deref(), Some("1234567890"));
911        assert_eq!(value.binary_security_token, "token");
912        assert_eq!(value.secret, "secret");
913    }
914
915    #[test]
916    fn deserialize_validation_response_defaults_info_messages() {
917        let payload = r#"{
918          "validationResults": {
919            "warningMessages": [],
920            "errorMessages": [],
921            "status": "PASS"
922          },
923          "reportingStatus": "REPORTED",
924          "clearanceStatus": null,
925          "qrSellertStatus": null,
926          "qrBuyertStatus": null
927        }"#;
928
929        let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
930        assert!(matches!(
931            parsed.validation_results.info_messages,
932            MessageList::Empty
933        ));
934    }
935
936    #[test]
937    fn csid_credentials_new_stores_env() {
938        let creds = CsidCredentials::<Compliance>::new(
939            EnvironmentType::Simulation,
940            Some("10".into()),
941            "token",
942            "secret",
943        );
944        assert_eq!(creds.env(), EnvironmentType::Simulation);
945        assert_eq!(creds.request_id(), Some("10"));
946    }
947
948    #[test]
949    fn response_getters_expose_fields() {
950        let payload = r#"{
951          "validationResults": {
952            "infoMessages": {
953              "type": "INFO",
954              "code": "INFO_CODE",
955              "category": "Info",
956              "message": "ok",
957              "status": "PASS"
958            },
959            "warningMessages": [
960              {
961                "type": "WARN",
962                "code": "WARN_CODE",
963                "category": "Warn",
964                "message": "warn",
965                "status": "WARN"
966              }
967            ],
968            "errorMessages": [],
969            "status": "PASS"
970          },
971          "reportingStatus": "REPORTED",
972          "clearanceStatus": "CLEARED",
973          "qrSellertStatus": "OK",
974          "qrBuyertStatus": "OK"
975        }"#;
976
977        let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
978        assert_eq!(parsed.reporting_status(), Some("REPORTED"));
979        assert_eq!(parsed.clearance_status(), Some("CLEARED"));
980        assert_eq!(parsed.qr_seller_status(), Some("OK"));
981        assert_eq!(parsed.qr_buyer_status(), Some("OK"));
982
983        let results = parsed.validation_results();
984        assert_eq!(results.status(), Some("PASS"));
985        assert_eq!(results.warning_messages().len(), 1);
986        assert_eq!(results.error_messages().len(), 0);
987
988        match results.info_messages() {
989            MessageList::One(message) => {
990                assert_eq!(message.message_type(), Some("INFO"));
991                assert_eq!(message.code(), Some("INFO_CODE"));
992                assert_eq!(message.category(), Some("Info"));
993                assert_eq!(message.message(), Some("ok"));
994                assert_eq!(message.status(), Some("PASS"));
995            }
996            _ => panic!("expected info message"),
997        }
998    }
999
1000    #[test]
1001    fn error_response_getters_expose_fields() {
1002        let unauthorized = UnauthorizedResponse {
1003            timestamp: Some(1),
1004            status: Some(401),
1005            error: Some("Unauthorized".into()),
1006            message: Some("nope".into()),
1007        };
1008        assert_eq!(unauthorized.timestamp(), Some(1));
1009        assert_eq!(unauthorized.status(), Some(401));
1010        assert_eq!(unauthorized.error(), Some("Unauthorized"));
1011        assert_eq!(unauthorized.message(), Some("nope"));
1012
1013        let server_error = ServerErrorResponse {
1014            category: Some("Server".into()),
1015            code: Some("ERR".into()),
1016            message: Some("boom".into()),
1017        };
1018        assert_eq!(server_error.category(), Some("Server"));
1019        assert_eq!(server_error.code(), Some("ERR"));
1020        assert_eq!(server_error.message(), Some("boom"));
1021    }
1022
1023    #[test]
1024    fn build_endpoint_trims_leading_slash() {
1025        let client = ZatcaClient::new(Config::default()).expect("client");
1026        let with_slash = client.build_endpoint("/invoices/reporting/single");
1027        let without_slash = client.build_endpoint("invoices/reporting/single");
1028        assert_eq!(with_slash, without_slash);
1029    }
1030
1031    #[test]
1032    fn ensure_env_rejects_mismatch() {
1033        let client = ZatcaClient::new(Config::default()).expect("client");
1034        let creds = CsidCredentials::<Compliance>::new(
1035            EnvironmentType::Production,
1036            None,
1037            "token",
1038            "secret",
1039        );
1040        let err = client.ensure_env(&creds).expect_err("env mismatch");
1041        assert!(matches!(err, ZatcaError::ClientState(_)));
1042    }
1043
1044    #[tokio::test]
1045    async fn report_rejects_standard_invoice() {
1046        let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1047
1048        let creds = CsidCredentials::new(
1049            EnvironmentType::NonProduction,
1050            None,
1051            "token",
1052            "secret",
1053        );
1054        let client = ZatcaClient::new(Config::default()).expect("client");
1055
1056        let result = client
1057            .report_simplified_invoice(&signed_invoice, &creds, false, None)
1058            .await;
1059        assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1060    }
1061
1062    #[tokio::test]
1063    async fn clearance_rejects_simplified_invoice() {
1064        let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1065
1066        let creds = CsidCredentials::new(
1067            EnvironmentType::NonProduction,
1068            None,
1069            "token",
1070            "secret",
1071        );
1072        let client = ZatcaClient::new(Config::default()).expect("client");
1073
1074        let result = client
1075            .clear_standard_invoice(&signed_invoice, &creds, true, None)
1076            .await;
1077        assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1078    }
1079
1080    #[tokio::test]
1081    async fn compliance_rejects_env_mismatch() {
1082        let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1083        let client = ZatcaClient::new(Config::default()).expect("client");
1084        let creds = CsidCredentials::new(
1085            EnvironmentType::Production,
1086            None,
1087            "token",
1088            "secret",
1089        );
1090
1091        let result = client.check_invoice_compliance(&signed_invoice, &creds).await;
1092        assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1093    }
1094
1095    #[tokio::test]
1096    async fn post_ccsid_requires_request_id() {
1097        let client = ZatcaClient::new(Config::default()).expect("client");
1098        let creds = CsidCredentials::new(
1099            EnvironmentType::NonProduction,
1100            None,
1101            "token",
1102            "secret",
1103        );
1104
1105        let result = client.post_ccsid_for_pcsid(&creds).await;
1106        assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1107    }
1108
1109    use std::sync::{Mutex, OnceLock};
1110
1111    fn base_url_lock() -> &'static Mutex<()> {
1112        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1113        LOCK.get_or_init(|| Mutex::new(()))
1114    }
1115
1116    struct BaseUrlGuard {
1117        _lock: std::sync::MutexGuard<'static, ()>,
1118        previous: Option<String>,
1119    }
1120
1121    impl BaseUrlGuard {
1122        fn new(url: &str) -> Self {
1123            let lock = base_url_lock().lock().expect("base url lock");
1124            let previous = std::env::var("FATOORA_ZATCA_BASE_URL").ok();
1125            unsafe {
1126                std::env::set_var("FATOORA_ZATCA_BASE_URL", url);
1127            }
1128            Self {
1129                _lock: lock,
1130                previous,
1131            }
1132        }
1133    }
1134
1135    impl Drop for BaseUrlGuard {
1136        fn drop(&mut self) {
1137            match self.previous.as_ref() {
1138                Some(value) => unsafe {
1139                    std::env::set_var("FATOORA_ZATCA_BASE_URL", value);
1140                },
1141                None => unsafe {
1142                    std::env::remove_var("FATOORA_ZATCA_BASE_URL");
1143                },
1144            }
1145        }
1146    }
1147
1148    fn try_start_server() -> Option<MockServer> {
1149        std::panic::catch_unwind(MockServer::start).ok()
1150    }
1151
1152    #[test]
1153    fn zatca_invoice_endpoints_use_base_url() {
1154        let server = match try_start_server() {
1155            Some(server) => server,
1156            None => return,
1157        };
1158        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1159        let body = r#"{
1160          "validationResults": {
1161            "infoMessages": [],
1162            "warningMessages": [],
1163            "errorMessages": [],
1164            "status": "PASS"
1165          },
1166          "reportingStatus": "REPORTED",
1167          "clearanceStatus": null,
1168          "qrSellertStatus": null,
1169          "qrBuyertStatus": null
1170        }"#;
1171
1172        let report_mock = server.mock(|when, then| {
1173            when.method(POST)
1174                .path("/invoices/reporting/single")
1175                .header("accept-language", "ar");
1176            then.status(200)
1177                .header("content-type", "application/json")
1178                .body(body);
1179        });
1180
1181        let clear_mock = server.mock(|when, then| {
1182            when.method(POST)
1183                .path("/invoices/clearance/single")
1184                .header("accept-language", "en");
1185            then.status(200)
1186                .header("content-type", "application/json")
1187                .body(body);
1188        });
1189
1190        let compliance_mock = server.mock(|when, then| {
1191            when.method(POST)
1192                .path("/compliance/invoices")
1193                .header("accept-language", "en");
1194            then.status(200)
1195                .header("content-type", "application/json")
1196                .body(body);
1197        });
1198
1199        let rt = tokio::runtime::Runtime::new().expect("runtime");
1200        rt.block_on(async {
1201            let client = ZatcaClient::new(Config::default()).expect("client");
1202            let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1203            let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1204            let pcsid = CsidCredentials::new(
1205                EnvironmentType::NonProduction,
1206                None,
1207                "token",
1208                "secret",
1209            );
1210            let ccsid = CsidCredentials::new(
1211                EnvironmentType::NonProduction,
1212                None,
1213                "token",
1214                "secret",
1215            );
1216
1217            let report = client
1218                .report_simplified_invoice(&simplified, &pcsid, false, Some("ar"))
1219                .await;
1220            assert!(report.is_ok());
1221            let clear = client
1222                .clear_standard_invoice(&standard, &pcsid, true, None)
1223                .await;
1224            assert!(clear.is_ok());
1225            let compliance = client.check_invoice_compliance(&simplified, &ccsid).await;
1226            assert!(compliance.is_ok());
1227
1228            report_mock.assert();
1229            clear_mock.assert();
1230            compliance_mock.assert();
1231        });
1232    }
1233
1234    #[test]
1235    fn zatca_csid_endpoints_use_base_url() {
1236        let server = match try_start_server() {
1237            Some(server) => server,
1238            None => return,
1239        };
1240        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1241        let ccsid_body = r#"{
1242          "requestID": 42,
1243          "binarySecurityToken": "token",
1244          "secret": "secret"
1245        }"#;
1246        let pcsid_body = r#"{
1247          "requestID": 77,
1248          "binarySecurityToken": "ptoken",
1249          "secret": "psecret"
1250        }"#;
1251        let renew_body = r#"{
1252          "value": {
1253            "requestID": 88,
1254            "binarySecurityToken": "rtoken",
1255            "secret": "rsecret"
1256          }
1257        }"#;
1258
1259        let csr_mock = server.mock(|when, then| {
1260            when.method(POST).path("/compliance").header("OTP", "123456");
1261            then.status(200)
1262                .header("content-type", "application/json")
1263                .body(ccsid_body);
1264        });
1265        let pcsid_mock = server.mock(|when, then| {
1266            when.method(POST).path("/production/csids");
1267            then.status(200)
1268                .header("content-type", "application/json")
1269                .body(pcsid_body);
1270        });
1271        let renew_mock = server.mock(|when, then| {
1272            when.method(PATCH)
1273                .path("/production/csids")
1274                .header("accept-language", "ar");
1275            then.status(428)
1276                .header("content-type", "application/json")
1277                .body(renew_body);
1278        });
1279
1280        let rt = tokio::runtime::Runtime::new().expect("runtime");
1281        rt.block_on(async {
1282            let client = ZatcaClient::new(Config::default()).expect("client");
1283            let csr = build_csr();
1284            let ccsid = client
1285                .post_csr_for_ccsid(&csr, "123456")
1286                .await
1287                .expect("ccsid");
1288            assert_eq!(ccsid.request_id(), Some("42"));
1289
1290            let pcsid = client
1291                .post_ccsid_for_pcsid(&ccsid)
1292                .await
1293                .expect("pcsid");
1294            assert_eq!(pcsid.request_id(), Some("77"));
1295
1296            let renewed = client
1297                .renew_csid(&pcsid, &csr, "123456", Some("ar"))
1298                .await
1299                .expect("renew");
1300            assert_eq!(renewed.request_id(), Some("88"));
1301
1302            csr_mock.assert();
1303            pcsid_mock.assert();
1304            renew_mock.assert();
1305        });
1306    }
1307
1308    #[test]
1309    fn report_handles_unauthorized_and_not_acceptable() {
1310        let server = match try_start_server() {
1311            Some(server) => server,
1312            None => return,
1313        };
1314        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1315        let unauthorized = r#"{"status":401,"error":"Unauthorized","message":"nope"}"#;
1316
1317        let mut unauthorized_mock = server.mock(|when, then| {
1318            when.method(POST).path("/invoices/reporting/single");
1319            then.status(401)
1320                .header("content-type", "application/json")
1321                .body(unauthorized);
1322        });
1323
1324        let not_acceptable_mock = server.mock(|when, then| {
1325            when.method(POST).path("/invoices/reporting/single");
1326            then.status(406).body("nope");
1327        });
1328
1329        let rt = tokio::runtime::Runtime::new().expect("runtime");
1330        rt.block_on(async {
1331            let client = ZatcaClient::new(Config::default()).expect("client");
1332            let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1333            let creds = CsidCredentials::new(
1334                EnvironmentType::NonProduction,
1335                None,
1336                "token",
1337                "secret",
1338            );
1339
1340            let result = client
1341                .report_simplified_invoice(&invoice, &creds, false, None)
1342                .await;
1343            assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1344
1345            unauthorized_mock.delete();
1346
1347            let result = client
1348                .report_simplified_invoice(&invoice, &creds, false, None)
1349                .await;
1350            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1351
1352            not_acceptable_mock.assert();
1353        });
1354    }
1355
1356    #[test]
1357    fn report_and_clear_handle_server_error_and_unauthorized() {
1358        let server = match try_start_server() {
1359            Some(server) => server,
1360            None => return,
1361        };
1362        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1363
1364        let report_mock = server.mock(|when, then| {
1365            when.method(POST).path("/invoices/reporting/single");
1366            then.status(500)
1367                .header("content-type", "application/json")
1368                .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1369        });
1370
1371        let clear_mock = server.mock(|when, then| {
1372            when.method(POST).path("/invoices/clearance/single");
1373            then.status(401)
1374                .header("content-type", "application/json")
1375                .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1376        });
1377
1378        let rt = tokio::runtime::Runtime::new().expect("runtime");
1379        rt.block_on(async {
1380            let client = ZatcaClient::new(Config::default()).expect("client");
1381            let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1382            let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1383            let pcsid = CsidCredentials::new(
1384                EnvironmentType::NonProduction,
1385                None,
1386                "token",
1387                "secret",
1388            );
1389
1390            let result = client
1391                .report_simplified_invoice(&simplified, &pcsid, false, None)
1392                .await;
1393            assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1394
1395            let result = client
1396                .clear_standard_invoice(&standard, &pcsid, true, None)
1397                .await;
1398            assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1399
1400            report_mock.assert();
1401            clear_mock.assert();
1402        });
1403    }
1404
1405    #[test]
1406    fn clear_and_compliance_error_paths() {
1407        let server = match try_start_server() {
1408            Some(server) => server,
1409            None => return,
1410        };
1411        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1412
1413        let clear_mock = server.mock(|when, then| {
1414            when.method(POST).path("/invoices/clearance/single");
1415            then.status(200).body("not json");
1416        });
1417
1418        let compliance_mock = server.mock(|when, then| {
1419            when.method(POST).path("/compliance/invoices");
1420            then.status(500)
1421                .header("content-type", "application/json")
1422                .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1423        });
1424
1425        let rt = tokio::runtime::Runtime::new().expect("runtime");
1426        rt.block_on(async {
1427            let client = ZatcaClient::new(Config::default()).expect("client");
1428            let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1429            let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1430            let pcsid = CsidCredentials::new(
1431                EnvironmentType::NonProduction,
1432                None,
1433                "token",
1434                "secret",
1435            );
1436            let ccsid = CsidCredentials::new(
1437                EnvironmentType::NonProduction,
1438                None,
1439                "token",
1440                "secret",
1441            );
1442
1443            let result = client
1444                .clear_standard_invoice(&standard, &pcsid, true, None)
1445                .await;
1446            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1447
1448            let result = client.check_invoice_compliance(&simplified, &ccsid).await;
1449            assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1450
1451            clear_mock.assert();
1452            compliance_mock.assert();
1453        });
1454    }
1455
1456    #[test]
1457    fn report_handles_conflict_response() {
1458        let server = match try_start_server() {
1459            Some(server) => server,
1460            None => return,
1461        };
1462        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1463        let body = r#"{
1464          "validationResults": {
1465            "infoMessages": [],
1466            "warningMessages": [],
1467            "errorMessages": [],
1468            "status": "PASS"
1469          },
1470          "reportingStatus": "REPORTED",
1471          "clearanceStatus": null,
1472          "qrSellertStatus": null,
1473          "qrBuyertStatus": null
1474        }"#;
1475
1476        let report_mock = server.mock(|when, then| {
1477            when.method(POST).path("/invoices/reporting/single");
1478            then.status(409)
1479                .header("content-type", "application/json")
1480                .body(body);
1481        });
1482
1483        let rt = tokio::runtime::Runtime::new().expect("runtime");
1484        rt.block_on(async {
1485            let client = ZatcaClient::new(Config::default()).expect("client");
1486            let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1487            let pcsid = CsidCredentials::new(
1488                EnvironmentType::NonProduction,
1489                None,
1490                "token",
1491                "secret",
1492            );
1493
1494            let result = client
1495                .report_simplified_invoice(&invoice, &pcsid, false, None)
1496                .await;
1497            assert!(result.is_ok());
1498
1499            report_mock.assert();
1500        });
1501    }
1502
1503    #[test]
1504    fn clear_handles_server_error() {
1505        let server = match try_start_server() {
1506            Some(server) => server,
1507            None => return,
1508        };
1509        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1510
1511        let clear_mock = server.mock(|when, then| {
1512            when.method(POST).path("/invoices/clearance/single");
1513            then.status(500)
1514                .header("content-type", "application/json")
1515                .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1516        });
1517
1518        let rt = tokio::runtime::Runtime::new().expect("runtime");
1519        rt.block_on(async {
1520            let client = ZatcaClient::new(Config::default()).expect("client");
1521            let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1522            let pcsid = CsidCredentials::new(
1523                EnvironmentType::NonProduction,
1524                None,
1525                "token",
1526                "secret",
1527            );
1528
1529            let result = client
1530                .clear_standard_invoice(&invoice, &pcsid, true, None)
1531                .await;
1532            assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1533
1534            clear_mock.assert();
1535        });
1536    }
1537
1538    #[test]
1539    fn compliance_handles_unauthorized() {
1540        let server = match try_start_server() {
1541            Some(server) => server,
1542            None => return,
1543        };
1544        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1545
1546        let compliance_mock = server.mock(|when, then| {
1547            when.method(POST).path("/compliance/invoices");
1548            then.status(401)
1549                .header("content-type", "application/json")
1550                .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1551        });
1552
1553        let rt = tokio::runtime::Runtime::new().expect("runtime");
1554        rt.block_on(async {
1555            let client = ZatcaClient::new(Config::default()).expect("client");
1556            let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1557            let ccsid = CsidCredentials::new(
1558                EnvironmentType::NonProduction,
1559                None,
1560                "token",
1561                "secret",
1562            );
1563
1564            let result = client.check_invoice_compliance(&invoice, &ccsid).await;
1565            assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1566
1567            compliance_mock.assert();
1568        });
1569    }
1570
1571    #[test]
1572    fn csid_requests_handle_invalid_and_unauthorized() {
1573        let server = match try_start_server() {
1574            Some(server) => server,
1575            None => return,
1576        };
1577        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1578
1579        let csr_mock = server.mock(|when, then| {
1580            when.method(POST).path("/compliance");
1581            then.status(400).body("bad");
1582        });
1583
1584        let pcsid_mock = server.mock(|when, then| {
1585            when.method(POST).path("/production/csids");
1586            then.status(400).body("bad");
1587        });
1588
1589        let renew_mock = server.mock(|when, then| {
1590            when.method(PATCH).path("/production/csids");
1591            then.status(401)
1592                .header("content-type", "application/json")
1593                .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1594        });
1595
1596        let rt = tokio::runtime::Runtime::new().expect("runtime");
1597        rt.block_on(async {
1598            let client = ZatcaClient::new(Config::default()).expect("client");
1599            let csr = build_csr();
1600
1601            let result = client.post_csr_for_ccsid(&csr, "123456").await;
1602            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1603
1604            let ccsid = CsidCredentials::new(
1605                EnvironmentType::NonProduction,
1606                Some("10".into()),
1607                "token",
1608                "secret",
1609            );
1610            let result = client.post_ccsid_for_pcsid(&ccsid).await;
1611            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1612
1613            let pcsid = CsidCredentials::new(
1614                EnvironmentType::NonProduction,
1615                Some("11".into()),
1616                "token",
1617                "secret",
1618            );
1619            let result = client
1620                .renew_csid(&pcsid, &csr, "123456", None)
1621                .await;
1622            assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1623
1624            csr_mock.assert();
1625            pcsid_mock.assert();
1626            renew_mock.assert();
1627        });
1628    }
1629
1630    #[test]
1631    fn compliance_and_renew_invalid_response_paths() {
1632        let server = match try_start_server() {
1633            Some(server) => server,
1634            None => return,
1635        };
1636        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1637
1638        let compliance_mock = server.mock(|when, then| {
1639            when.method(POST).path("/compliance/invoices");
1640            then.status(200).body("not json");
1641        });
1642
1643        let renew_mock = server.mock(|when, then| {
1644            when.method(PATCH).path("/production/csids");
1645            then.status(418).body("nope");
1646        });
1647
1648        let rt = tokio::runtime::Runtime::new().expect("runtime");
1649        rt.block_on(async {
1650            let client = ZatcaClient::new(Config::default()).expect("client");
1651            let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1652            let ccsid = CsidCredentials::new(
1653                EnvironmentType::NonProduction,
1654                None,
1655                "token",
1656                "secret",
1657            );
1658            let pcsid = CsidCredentials::new(
1659                EnvironmentType::NonProduction,
1660                Some("1".into()),
1661                "token",
1662                "secret",
1663            );
1664            let csr = build_csr();
1665
1666            let result = client.check_invoice_compliance(&invoice, &ccsid).await;
1667            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1668
1669            let result = client
1670                .renew_csid(&pcsid, &csr, "123456", None)
1671                .await;
1672            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1673
1674            compliance_mock.assert();
1675            renew_mock.assert();
1676        });
1677    }
1678
1679    #[test]
1680    fn csid_requests_handle_invalid_json() {
1681        let server = match try_start_server() {
1682            Some(server) => server,
1683            None => return,
1684        };
1685        let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1686
1687        let csr_mock = server.mock(|when, then| {
1688            when.method(POST).path("/compliance");
1689            then.status(200).body("not json");
1690        });
1691
1692        let pcsid_mock = server.mock(|when, then| {
1693            when.method(POST).path("/production/csids");
1694            then.status(200).body("not json");
1695        });
1696
1697        let rt = tokio::runtime::Runtime::new().expect("runtime");
1698        rt.block_on(async {
1699            let client = ZatcaClient::new(Config::default()).expect("client");
1700            let csr = build_csr();
1701
1702            let result = client.post_csr_for_ccsid(&csr, "123456").await;
1703            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1704
1705            let ccsid = CsidCredentials::new(
1706                EnvironmentType::NonProduction,
1707                Some("10".into()),
1708                "token",
1709                "secret",
1710            );
1711            let result = client.post_ccsid_for_pcsid(&ccsid).await;
1712            assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1713
1714            csr_mock.assert();
1715            pcsid_mock.assert();
1716        });
1717    }
1718
1719    fn build_csr() -> Csr {
1720        let config_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1721            .join("tests/fixtures/csr-configs/csr-config-example-EN.properties");
1722        let csr_props = std::fs::read_to_string(&config_path).expect("read csr config");
1723        let csr_config = CsrProperties::from_properties_str(&csr_props).expect("csr config");
1724        let key = crate::csr::SigningKey::generate();
1725        csr_config
1726            .build(&key, EnvironmentType::NonProduction)
1727            .expect("csr build")
1728    }
1729
1730    fn build_signed_invoice(invoice_type: InvoiceType) -> SignedInvoice {
1731        let seller = Party::<SellerRole>::new(
1732            "Acme Inc".into(),
1733            Address {
1734                country_code: CountryCode::parse("SAU").expect("country code"),
1735                city: "Riyadh".into(),
1736                street: "King Fahd".into(),
1737                additional_street: None,
1738                building_number: "1234".into(),
1739                additional_number: Some("5678".into()),
1740                postal_code: "12222".into(),
1741                subdivision: None,
1742                district: None,
1743            },
1744            "301121971500003",
1745            None,
1746        )
1747        .expect("seller");
1748
1749        let line_item = LineItem::new("Item", 1.0, "PCE", 100.0, 15.0, VatCategory::Standard);
1750
1751        let mut builder = InvoiceBuilder::new(invoice_type);
1752        builder
1753            .set_id("INV-TEST-1")
1754            .set_uuid("uuid-test-1")
1755            .set_issue_datetime("2024-01-01T12:30:00Z")
1756            .set_currency("SAR")
1757            .set_previous_invoice_hash("hash")
1758            .set_invoice_counter(0)
1759            .set_seller(seller)
1760            .set_payment_means_code("10")
1761            .set_vat_category(VatCategory::Standard)
1762            .add_line_item(line_item);
1763        let invoice = builder.build().expect("build invoice");
1764
1765        let signed_xml = invoice.to_xml().expect("serialize invoice");
1766        let public_key_b64 = Base64::encode_string(b"pk");
1767        let signing =
1768            SignedProperties::from_qr_parts("hash==", "signature==", &public_key_b64, None);
1769        invoice
1770            .sign_with_bundle(signing, signed_xml)
1771            .expect("sign invoice")
1772    }
1773}