Skip to main content

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