Skip to main content

yuki_client/client/
archive.rs

1use quick_xml::Reader;
2use quick_xml::events::Event;
3
4use crate::error::YukiError;
5
6use super::local_name;
7use super::soap_client::{SoapClient, SoapEnvelope};
8
9const BASE_URL: &str = "https://api.yukiworks.nl/ws/Archive.asmx";
10
11/// A Yuki cost category.
12#[derive(Debug, Clone)]
13pub struct CostCategory {
14    pub id: String,
15    pub description: String,
16}
17
18/// A Yuki payment method.
19#[derive(Debug, Clone)]
20pub struct PaymentMethod {
21    pub id: String,
22    pub description: String,
23}
24
25/// A document returned by the Yuki archive search.
26#[derive(Debug, Clone)]
27pub struct ArchiveDocument {
28    pub id: String,
29    pub subject: String,
30    pub document_date: String,
31    pub amount: String,
32    pub folder: String,
33    pub contact_name: String,
34    pub file_name: String,
35    pub reference: String,
36}
37
38/// Client for the Yuki Archive SOAP service.
39pub struct ArchiveClient {
40    soap: SoapClient,
41}
42
43impl ArchiveClient {
44    pub fn new() -> Self {
45        Self {
46            soap: SoapClient::new(BASE_URL),
47        }
48    }
49
50    /// Build over a caller-provided HTTP client, so a long-running consumer can
51    /// share a single pooled client across all service clients.
52    pub fn with_client(http: reqwest::Client) -> Self {
53        Self {
54            soap: SoapClient::with_client(BASE_URL, http),
55        }
56    }
57
58    fn require_session(&self) -> Result<&str, YukiError> {
59        self.soap.session_id().ok_or_else(|| {
60            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
61        })
62    }
63
64    /// Authenticate with the Yuki API and store the session ID.
65    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
66        self.soap.authenticate(api_key).await
67    }
68
69    /// List all documents in an archive folder by folder ID.
70    pub async fn documents_in_folder(
71        &self,
72        folder_id: i32,
73        start_date: &str,
74        end_date: &str,
75    ) -> Result<Vec<ArchiveDocument>, YukiError> {
76        let session = self.require_session()?;
77        let envelope = SoapEnvelope::new("DocumentsInFolder")
78            .session(session)
79            .param("folderID", &folder_id.to_string())
80            .param("sortOrder", "DocumentDateDesc")
81            .param("startDate", start_date)
82            .param("endDate", end_date)
83            .param("numberOfRecords", "100")
84            .param("startRecord", "0")
85            .build();
86        let body = self.soap.call("DocumentsInFolder", envelope).await?;
87        Self::parse_archive_documents(&body)
88    }
89
90    /// List all documents of a given document type.
91    pub async fn documents_by_type(&self, doc_type: i32) -> Result<String, YukiError> {
92        let session = self.require_session()?;
93        let envelope = SoapEnvelope::new("DocumentsByType")
94            .session(session)
95            .param("documentType", &doc_type.to_string())
96            .build();
97        self.soap.call("DocumentsByType", envelope).await
98    }
99
100    /// Search documents in the archive using a free-text query within a date range.
101    ///
102    /// Pass an empty string for `search_text` to retrieve all documents in the period.
103    /// Returns up to 500 results sorted by document date descending.
104    pub async fn search_documents(
105        &self,
106        search_text: &str,
107        start_date: &str,
108        end_date: &str,
109    ) -> Result<Vec<ArchiveDocument>, YukiError> {
110        let session = self.require_session()?;
111        let envelope = SoapEnvelope::new("SearchDocuments")
112            .session(session)
113            .param("searchOption", "All")
114            .param("searchText", search_text)
115            .param("folderID", "-1")
116            .param("tabID", "-1")
117            .param("sortOrder", "DocumentDateDesc")
118            .param("startDate", start_date)
119            .param("endDate", end_date)
120            .param("numberOfRecords", "500")
121            .param("startRecord", "0")
122            .build();
123        let body = self.soap.call("SearchDocuments", envelope).await?;
124        Self::parse_archive_documents(&body)
125    }
126
127    /// List documents of a given type that were modified since the specified date.
128    pub async fn modified_documents_by_type(
129        &self,
130        doc_type: i32,
131        modified_since: &str,
132    ) -> Result<String, YukiError> {
133        let session = self.require_session()?;
134        let envelope = SoapEnvelope::new("ModifiedDocumentsByType")
135            .session(session)
136            .param("documentType", &doc_type.to_string())
137            .param("modifiedSince", modified_since)
138            .build();
139        self.soap.call("ModifiedDocumentsByType", envelope).await
140    }
141
142    /// Upload a document to the archive without additional metadata.
143    ///
144    /// Returns the document ID assigned by Yuki.
145    pub async fn upload_document(
146        &self,
147        admin_id: &str,
148        filename: &str,
149        data_base64: &str,
150        folder_id: i32,
151    ) -> Result<String, YukiError> {
152        let session = self.require_session()?;
153        let envelope = SoapEnvelope::new("UploadDocument")
154            .session(session)
155            .param("fileName", filename)
156            .param("data", data_base64)
157            .param("folder", &folder_id.to_string())
158            .param("administrationID", admin_id)
159            .build();
160        let body = self.soap.call("UploadDocument", envelope).await?;
161        SoapClient::parse_single_result(&body, "UploadDocumentResult")
162    }
163
164    /// Upload a document to the archive with invoice metadata.
165    ///
166    /// Returns the document ID assigned by Yuki.
167    #[allow(clippy::too_many_arguments)]
168    pub async fn upload_document_with_data(
169        &self,
170        admin_id: &str,
171        filename: &str,
172        data_base64: &str,
173        folder_id: i32,
174        currency: &str,
175        amount: f64,
176        cost_category: Option<&str>,
177        payment_method: Option<&str>,
178        project: Option<&str>,
179        remarks: Option<&str>,
180    ) -> Result<String, YukiError> {
181        let session = self.require_session()?;
182        let amount_str = format!("{amount:.2}");
183        let envelope = SoapEnvelope::new("UploadDocumentWithData")
184            .session(session)
185            .param("fileName", filename)
186            .param("data", data_base64)
187            .param("folder", &folder_id.to_string())
188            .param("administrationID", admin_id)
189            .param("currency", currency)
190            .param("amount", &amount_str)
191            .param("costCategory", cost_category.unwrap_or(""))
192            .param("paymentMethod", payment_method.unwrap_or("0"))
193            .param("project", project.unwrap_or(""))
194            .param("remarks", remarks.unwrap_or(""))
195            .build();
196        let body = self.soap.call("UploadDocumentWithData", envelope).await?;
197        SoapClient::parse_single_result(&body, "UploadDocumentWithDataResult")
198    }
199
200    /// Retrieve all available cost categories.
201    pub async fn cost_categories(&self) -> Result<Vec<CostCategory>, YukiError> {
202        let session = self.require_session()?;
203        let envelope = SoapEnvelope::new("CostCategories").session(session).build();
204        let body = self.soap.call("CostCategories", envelope).await?;
205        Self::parse_cost_categories(&body)
206    }
207
208    /// Retrieve all available payment methods.
209    pub async fn payment_methods(&self) -> Result<Vec<PaymentMethod>, YukiError> {
210        let session = self.require_session()?;
211        let envelope = SoapEnvelope::new("PaymentMethods").session(session).build();
212        let body = self.soap.call("PaymentMethods", envelope).await?;
213        Self::parse_payment_methods(&body)
214    }
215
216    /// Parse a SearchDocuments or DocumentsInFolder SOAP response into a list of documents.
217    ///
218    /// Each `<Document ID="uuid">` element carries child elements for each field.
219    /// The document ID is an XML attribute; all other fields are child text nodes.
220    pub fn parse_archive_documents(xml: &str) -> Result<Vec<ArchiveDocument>, YukiError> {
221        let mut reader = Reader::from_str(xml);
222        reader.config_mut().trim_text(true);
223
224        let mut documents = Vec::new();
225        let mut in_document = false;
226        let mut current_field = String::new();
227        let mut doc = ArchiveDocument {
228            id: String::new(),
229            subject: String::new(),
230            document_date: String::new(),
231            amount: String::new(),
232            folder: String::new(),
233            contact_name: String::new(),
234            file_name: String::new(),
235            reference: String::new(),
236        };
237        let mut buf = Vec::new();
238
239        loop {
240            match reader.read_event_into(&mut buf) {
241                Ok(Event::Start(ref e)) => {
242                    let local = local_name(e.name().as_ref()).to_string();
243                    match local.as_str() {
244                        "Document" => {
245                            in_document = true;
246                            doc = ArchiveDocument {
247                                id: String::new(),
248                                subject: String::new(),
249                                document_date: String::new(),
250                                amount: String::new(),
251                                folder: String::new(),
252                                contact_name: String::new(),
253                                file_name: String::new(),
254                                reference: String::new(),
255                            };
256                            for attr in e.attributes().flatten() {
257                                if attr.key.as_ref() == b"ID" {
258                                    doc.id = String::from_utf8_lossy(&attr.value).to_string();
259                                }
260                            }
261                        }
262                        "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
263                        | "FileName" | "Reference"
264                            if in_document =>
265                        {
266                            current_field = local;
267                        }
268                        _ => {}
269                    }
270                }
271                Ok(Event::Text(ref e)) if in_document && !current_field.is_empty() => {
272                    let text = e
273                        .unescape()
274                        .map_err(|e| YukiError::Xml(e.to_string()))?
275                        .trim()
276                        .to_string();
277                    match current_field.as_str() {
278                        "Subject" => doc.subject = text,
279                        "DocumentDate" => doc.document_date = text,
280                        "Amount" => doc.amount = text,
281                        "Folder" => doc.folder = text,
282                        "ContactName" => doc.contact_name = text,
283                        "FileName" => doc.file_name = text,
284                        "Reference" => doc.reference = text,
285                        _ => {}
286                    }
287                }
288                Ok(Event::End(ref e)) => {
289                    let name = e.name();
290                    let local = local_name(name.as_ref());
291                    match local {
292                        "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
293                        | "FileName" | "Reference" => {
294                            current_field.clear();
295                        }
296                        "Document" => {
297                            if !doc.id.is_empty() {
298                                documents.push(doc.clone());
299                            }
300                            in_document = false;
301                        }
302                        _ => {}
303                    }
304                }
305                Ok(Event::Eof) => break,
306                Err(e) => return Err(YukiError::Xml(e.to_string())),
307                _ => {}
308            }
309            buf.clear();
310        }
311
312        Ok(documents)
313    }
314
315    /// Parse a CostCategories SOAP response.
316    ///
317    /// Each `CostCategory` element carries an `ID` attribute and a `Description` child element:
318    /// `<CostCategory ID="45100"><Description>...</Description></CostCategory>`
319    pub fn parse_cost_categories(xml: &str) -> Result<Vec<CostCategory>, YukiError> {
320        let mut reader = Reader::from_str(xml);
321        reader.config_mut().trim_text(true);
322
323        let mut categories = Vec::new();
324        let mut current_id = String::new();
325        let mut current_desc = String::new();
326        let mut in_category = false;
327        let mut in_description = false;
328        let mut buf = Vec::new();
329
330        loop {
331            match reader.read_event_into(&mut buf) {
332                Ok(Event::Start(ref e)) => {
333                    let local = local_name(e.name().as_ref()).to_string();
334                    match local.as_str() {
335                        "CostCategory" => {
336                            in_category = true;
337                            current_id.clear();
338                            current_desc.clear();
339                            for attr in e.attributes().flatten() {
340                                if attr.key.as_ref() == b"ID" {
341                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
342                                }
343                            }
344                        }
345                        "Description" if in_category => {
346                            in_description = true;
347                        }
348                        _ => {}
349                    }
350                }
351                Ok(Event::Text(ref e)) if in_description => {
352                    current_desc = e
353                        .unescape()
354                        .map_err(|e| YukiError::Xml(e.to_string()))?
355                        .trim()
356                        .to_string();
357                }
358                Ok(Event::End(ref e)) => {
359                    let name = e.name();
360                    let local = local_name(name.as_ref());
361                    match local {
362                        "Description" => in_description = false,
363                        "CostCategory" => {
364                            if !current_id.is_empty() {
365                                categories.push(CostCategory {
366                                    id: current_id.clone(),
367                                    description: current_desc.clone(),
368                                });
369                            }
370                            in_category = false;
371                        }
372                        _ => {}
373                    }
374                }
375                Ok(Event::Eof) => break,
376                Err(e) => return Err(YukiError::Xml(e.to_string())),
377                _ => {}
378            }
379            buf.clear();
380        }
381
382        Ok(categories)
383    }
384
385    /// Parse a PaymentMethods SOAP response.
386    ///
387    /// Each `PaymentMethod` element carries an `ID` attribute and a `Description` child element:
388    /// `<PaymentMethod ID="4"><Description>...</Description></PaymentMethod>`
389    pub fn parse_payment_methods(xml: &str) -> Result<Vec<PaymentMethod>, YukiError> {
390        let mut reader = Reader::from_str(xml);
391        reader.config_mut().trim_text(true);
392
393        let mut methods = Vec::new();
394        let mut current_id = String::new();
395        let mut current_desc = String::new();
396        let mut in_method = false;
397        let mut in_description = false;
398        let mut buf = Vec::new();
399
400        loop {
401            match reader.read_event_into(&mut buf) {
402                Ok(Event::Start(ref e)) => {
403                    let local = local_name(e.name().as_ref()).to_string();
404                    match local.as_str() {
405                        "PaymentMethod" => {
406                            in_method = true;
407                            current_id.clear();
408                            current_desc.clear();
409                            for attr in e.attributes().flatten() {
410                                if attr.key.as_ref() == b"ID" {
411                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
412                                }
413                            }
414                        }
415                        "Description" if in_method => {
416                            in_description = true;
417                        }
418                        _ => {}
419                    }
420                }
421                Ok(Event::Text(ref e)) if in_description => {
422                    current_desc = e
423                        .unescape()
424                        .map_err(|e| YukiError::Xml(e.to_string()))?
425                        .trim()
426                        .to_string();
427                }
428                Ok(Event::End(ref e)) => {
429                    let name = e.name();
430                    let local = local_name(name.as_ref());
431                    match local {
432                        "Description" => in_description = false,
433                        "PaymentMethod" => {
434                            if !current_id.is_empty() {
435                                methods.push(PaymentMethod {
436                                    id: current_id.clone(),
437                                    description: current_desc.clone(),
438                                });
439                            }
440                            in_method = false;
441                        }
442                        _ => {}
443                    }
444                }
445                Ok(Event::Eof) => break,
446                Err(e) => return Err(YukiError::Xml(e.to_string())),
447                _ => {}
448            }
449            buf.clear();
450        }
451
452        Ok(methods)
453    }
454}
455
456impl Default for ArchiveClient {
457    fn default() -> Self {
458        Self::new()
459    }
460}