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    /// Number of records requested per `DocumentsInFolder` round trip.
70    const FOLDER_PAGE_SIZE: usize = 500;
71
72    /// Fetch one page of documents from an archive folder.
73    pub async fn documents_in_folder_page(
74        &self,
75        folder_id: i32,
76        start_date: &str,
77        end_date: &str,
78        number_of_records: usize,
79        start_record: usize,
80    ) -> Result<Vec<ArchiveDocument>, YukiError> {
81        let session = self.require_session()?;
82        let envelope = SoapEnvelope::new("DocumentsInFolder")
83            .session(session)
84            .param("folderID", &folder_id.to_string())
85            .param("sortOrder", "DocumentDateDesc")
86            .param("startDate", start_date)
87            .param("endDate", end_date)
88            .param("numberOfRecords", &number_of_records.to_string())
89            .param("startRecord", &start_record.to_string())
90            .build();
91        let body = self.soap.call("DocumentsInFolder", envelope).await?;
92        Self::parse_archive_documents(&body)
93    }
94
95    /// List every document in an archive folder.
96    pub async fn documents_in_folder(
97        &self,
98        folder_id: i32,
99        start_date: &str,
100        end_date: &str,
101    ) -> Result<Vec<ArchiveDocument>, YukiError> {
102        self.documents_in_folder_paged(folder_id, start_date, end_date, None, None)
103            .await
104    }
105
106    /// List documents in an archive folder, paging until the folder is exhausted.
107    ///
108    /// `offset` skips records server-side; `limit` caps the total returned. With no
109    /// limit every document is fetched, so a folder larger than one page is never
110    /// silently truncated.
111    pub async fn documents_in_folder_paged(
112        &self,
113        folder_id: i32,
114        start_date: &str,
115        end_date: &str,
116        limit: Option<usize>,
117        offset: Option<usize>,
118    ) -> Result<Vec<ArchiveDocument>, YukiError> {
119        let mut collected: Vec<ArchiveDocument> = Vec::new();
120        let mut start_record = offset.unwrap_or(0);
121
122        while let Some(want) = next_page_size(limit, collected.len(), Self::FOLDER_PAGE_SIZE) {
123            let page = self
124                .documents_in_folder_page(folder_id, start_date, end_date, want, start_record)
125                .await?;
126            let received = page.len();
127            collected.extend(page);
128
129            // A short page means the folder is exhausted.
130            if received < want {
131                break;
132            }
133            start_record += received;
134        }
135
136        Ok(collected)
137    }
138
139    /// List all documents of a given document type.
140    pub async fn documents_by_type(&self, doc_type: i32) -> Result<String, YukiError> {
141        let session = self.require_session()?;
142        let envelope = SoapEnvelope::new("DocumentsByType")
143            .session(session)
144            .param("documentType", &doc_type.to_string())
145            .build();
146        self.soap.call("DocumentsByType", envelope).await
147    }
148
149    /// Search documents in the archive using a free-text query within a date range.
150    ///
151    /// Pass an empty string for `search_text` to retrieve all documents in the period.
152    /// Returns up to 500 results sorted by document date descending.
153    pub async fn search_documents(
154        &self,
155        search_text: &str,
156        start_date: &str,
157        end_date: &str,
158    ) -> Result<Vec<ArchiveDocument>, YukiError> {
159        let session = self.require_session()?;
160        let envelope = SoapEnvelope::new("SearchDocuments")
161            .session(session)
162            .param("searchOption", "All")
163            .param("searchText", search_text)
164            .param("folderID", "-1")
165            .param("tabID", "-1")
166            .param("sortOrder", "DocumentDateDesc")
167            .param("startDate", start_date)
168            .param("endDate", end_date)
169            .param("numberOfRecords", "500")
170            .param("startRecord", "0")
171            .build();
172        let body = self.soap.call("SearchDocuments", envelope).await?;
173        Self::parse_archive_documents(&body)
174    }
175
176    /// List documents of a given type that were modified since the specified date.
177    pub async fn modified_documents_by_type(
178        &self,
179        doc_type: i32,
180        modified_since: &str,
181    ) -> Result<String, YukiError> {
182        let session = self.require_session()?;
183        let envelope = SoapEnvelope::new("ModifiedDocumentsByType")
184            .session(session)
185            .param("documentType", &doc_type.to_string())
186            .param("modifiedSince", modified_since)
187            .build();
188        self.soap.call("ModifiedDocumentsByType", envelope).await
189    }
190
191    /// Upload a document to the archive without additional metadata.
192    ///
193    /// Returns the document ID assigned by Yuki.
194    pub async fn upload_document(
195        &self,
196        admin_id: &str,
197        filename: &str,
198        data_base64: &str,
199        folder_id: i32,
200    ) -> Result<String, YukiError> {
201        let session = self.require_session()?;
202        let envelope = SoapEnvelope::new("UploadDocument")
203            .session(session)
204            .param("fileName", filename)
205            .param("data", data_base64)
206            .param("folder", &folder_id.to_string())
207            .param("administrationID", admin_id)
208            .build();
209        let body = self.soap.call("UploadDocument", envelope).await?;
210        SoapClient::parse_single_result(&body, "UploadDocumentResult")
211    }
212
213    /// Upload a document to the archive with invoice metadata.
214    ///
215    /// Returns the document ID assigned by Yuki.
216    #[allow(clippy::too_many_arguments)]
217    pub async fn upload_document_with_data(
218        &self,
219        admin_id: &str,
220        filename: &str,
221        data_base64: &str,
222        folder_id: i32,
223        currency: &str,
224        amount: f64,
225        cost_category: Option<&str>,
226        payment_method: Option<&str>,
227        project: Option<&str>,
228        remarks: Option<&str>,
229    ) -> Result<String, YukiError> {
230        let session = self.require_session()?;
231        let amount_str = format!("{amount:.2}");
232        let envelope = SoapEnvelope::new("UploadDocumentWithData")
233            .session(session)
234            .param("fileName", filename)
235            .param("data", data_base64)
236            .param("folder", &folder_id.to_string())
237            .param("administrationID", admin_id)
238            .param("currency", currency)
239            .param("amount", &amount_str)
240            .param("costCategory", cost_category.unwrap_or(""))
241            .param("paymentMethod", payment_method.unwrap_or("0"))
242            .param("project", project.unwrap_or(""))
243            .param("remarks", remarks.unwrap_or(""))
244            .build();
245        let body = self.soap.call("UploadDocumentWithData", envelope).await?;
246        SoapClient::parse_single_result(&body, "UploadDocumentWithDataResult")
247    }
248
249    /// Retrieve all available cost categories.
250    pub async fn cost_categories(&self) -> Result<Vec<CostCategory>, YukiError> {
251        let session = self.require_session()?;
252        let envelope = SoapEnvelope::new("CostCategories").session(session).build();
253        let body = self.soap.call("CostCategories", envelope).await?;
254        Self::parse_cost_categories(&body)
255    }
256
257    /// Retrieve all available payment methods.
258    pub async fn payment_methods(&self) -> Result<Vec<PaymentMethod>, YukiError> {
259        let session = self.require_session()?;
260        let envelope = SoapEnvelope::new("PaymentMethods").session(session).build();
261        let body = self.soap.call("PaymentMethods", envelope).await?;
262        Self::parse_payment_methods(&body)
263    }
264
265    /// Parse a SearchDocuments or DocumentsInFolder SOAP response into a list of documents.
266    ///
267    /// Each `<Document ID="uuid">` element carries child elements for each field.
268    /// The document ID is an XML attribute; all other fields are child text nodes.
269    pub fn parse_archive_documents(xml: &str) -> Result<Vec<ArchiveDocument>, YukiError> {
270        let mut reader = Reader::from_str(xml);
271        reader.config_mut().trim_text(true);
272
273        let mut documents = Vec::new();
274        let mut in_document = false;
275        let mut current_field = String::new();
276        let mut doc = ArchiveDocument {
277            id: String::new(),
278            subject: String::new(),
279            document_date: String::new(),
280            amount: String::new(),
281            folder: String::new(),
282            contact_name: String::new(),
283            file_name: String::new(),
284            reference: String::new(),
285        };
286        let mut buf = Vec::new();
287
288        loop {
289            match reader.read_event_into(&mut buf) {
290                Ok(Event::Start(ref e)) => {
291                    let local = local_name(e.name().as_ref()).to_string();
292                    match local.as_str() {
293                        "Document" => {
294                            in_document = true;
295                            doc = ArchiveDocument {
296                                id: String::new(),
297                                subject: String::new(),
298                                document_date: String::new(),
299                                amount: String::new(),
300                                folder: String::new(),
301                                contact_name: String::new(),
302                                file_name: String::new(),
303                                reference: String::new(),
304                            };
305                            for attr in e.attributes().flatten() {
306                                if attr.key.as_ref() == b"ID" {
307                                    doc.id = String::from_utf8_lossy(&attr.value).to_string();
308                                }
309                            }
310                        }
311                        "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
312                        | "FileName" | "Reference"
313                            if in_document =>
314                        {
315                            current_field = local;
316                        }
317                        _ => {}
318                    }
319                }
320                Ok(Event::Text(ref e)) if in_document && !current_field.is_empty() => {
321                    let text = e
322                        .unescape()
323                        .map_err(|e| YukiError::Xml(e.to_string()))?
324                        .trim()
325                        .to_string();
326                    match current_field.as_str() {
327                        "Subject" => doc.subject = text,
328                        "DocumentDate" => doc.document_date = text,
329                        "Amount" => doc.amount = text,
330                        "Folder" => doc.folder = text,
331                        "ContactName" => doc.contact_name = text,
332                        "FileName" => doc.file_name = text,
333                        "Reference" => doc.reference = text,
334                        _ => {}
335                    }
336                }
337                Ok(Event::End(ref e)) => {
338                    let name = e.name();
339                    let local = local_name(name.as_ref());
340                    match local {
341                        "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
342                        | "FileName" | "Reference" => {
343                            current_field.clear();
344                        }
345                        "Document" => {
346                            if !doc.id.is_empty() {
347                                documents.push(doc.clone());
348                            }
349                            in_document = false;
350                        }
351                        _ => {}
352                    }
353                }
354                Ok(Event::Eof) => break,
355                Err(e) => return Err(YukiError::Xml(e.to_string())),
356                _ => {}
357            }
358            buf.clear();
359        }
360
361        Ok(documents)
362    }
363
364    /// Parse a CostCategories SOAP response.
365    ///
366    /// Each `CostCategory` element carries an `ID` attribute and a `Description` child element:
367    /// `<CostCategory ID="45100"><Description>...</Description></CostCategory>`
368    pub fn parse_cost_categories(xml: &str) -> Result<Vec<CostCategory>, YukiError> {
369        let mut reader = Reader::from_str(xml);
370        reader.config_mut().trim_text(true);
371
372        let mut categories = Vec::new();
373        let mut current_id = String::new();
374        let mut current_desc = String::new();
375        let mut in_category = false;
376        let mut in_description = false;
377        let mut buf = Vec::new();
378
379        loop {
380            match reader.read_event_into(&mut buf) {
381                Ok(Event::Start(ref e)) => {
382                    let local = local_name(e.name().as_ref()).to_string();
383                    match local.as_str() {
384                        "CostCategory" => {
385                            in_category = true;
386                            current_id.clear();
387                            current_desc.clear();
388                            for attr in e.attributes().flatten() {
389                                if attr.key.as_ref() == b"ID" {
390                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
391                                }
392                            }
393                        }
394                        "Description" if in_category => {
395                            in_description = true;
396                        }
397                        _ => {}
398                    }
399                }
400                Ok(Event::Text(ref e)) if in_description => {
401                    current_desc = e
402                        .unescape()
403                        .map_err(|e| YukiError::Xml(e.to_string()))?
404                        .trim()
405                        .to_string();
406                }
407                Ok(Event::End(ref e)) => {
408                    let name = e.name();
409                    let local = local_name(name.as_ref());
410                    match local {
411                        "Description" => in_description = false,
412                        "CostCategory" => {
413                            if !current_id.is_empty() {
414                                categories.push(CostCategory {
415                                    id: current_id.clone(),
416                                    description: current_desc.clone(),
417                                });
418                            }
419                            in_category = false;
420                        }
421                        _ => {}
422                    }
423                }
424                Ok(Event::Eof) => break,
425                Err(e) => return Err(YukiError::Xml(e.to_string())),
426                _ => {}
427            }
428            buf.clear();
429        }
430
431        Ok(categories)
432    }
433
434    /// Parse a PaymentMethods SOAP response.
435    ///
436    /// Each `PaymentMethod` element carries an `ID` attribute and a `Description` child element:
437    /// `<PaymentMethod ID="4"><Description>...</Description></PaymentMethod>`
438    pub fn parse_payment_methods(xml: &str) -> Result<Vec<PaymentMethod>, YukiError> {
439        let mut reader = Reader::from_str(xml);
440        reader.config_mut().trim_text(true);
441
442        let mut methods = Vec::new();
443        let mut current_id = String::new();
444        let mut current_desc = String::new();
445        let mut in_method = false;
446        let mut in_description = false;
447        let mut buf = Vec::new();
448
449        loop {
450            match reader.read_event_into(&mut buf) {
451                Ok(Event::Start(ref e)) => {
452                    let local = local_name(e.name().as_ref()).to_string();
453                    match local.as_str() {
454                        "PaymentMethod" => {
455                            in_method = true;
456                            current_id.clear();
457                            current_desc.clear();
458                            for attr in e.attributes().flatten() {
459                                if attr.key.as_ref() == b"ID" {
460                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
461                                }
462                            }
463                        }
464                        "Description" if in_method => {
465                            in_description = true;
466                        }
467                        _ => {}
468                    }
469                }
470                Ok(Event::Text(ref e)) if in_description => {
471                    current_desc = e
472                        .unescape()
473                        .map_err(|e| YukiError::Xml(e.to_string()))?
474                        .trim()
475                        .to_string();
476                }
477                Ok(Event::End(ref e)) => {
478                    let name = e.name();
479                    let local = local_name(name.as_ref());
480                    match local {
481                        "Description" => in_description = false,
482                        "PaymentMethod" => {
483                            if !current_id.is_empty() {
484                                methods.push(PaymentMethod {
485                                    id: current_id.clone(),
486                                    description: current_desc.clone(),
487                                });
488                            }
489                            in_method = false;
490                        }
491                        _ => {}
492                    }
493                }
494                Ok(Event::Eof) => break,
495                Err(e) => return Err(YukiError::Xml(e.to_string())),
496                _ => {}
497            }
498            buf.clear();
499        }
500
501        Ok(methods)
502    }
503}
504
505impl Default for ArchiveClient {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511/// How many records to request next, or `None` when the caller's limit is satisfied.
512///
513/// With no limit this always asks for a full page, so a folder larger than one page
514/// keeps paging instead of being silently truncated at the first response.
515pub(crate) fn next_page_size(
516    limit: Option<usize>,
517    collected: usize,
518    page_size: usize,
519) -> Option<usize> {
520    match limit {
521        Some(l) => {
522            let remaining = l.saturating_sub(collected);
523            (remaining > 0).then(|| remaining.min(page_size))
524        }
525        None => Some(page_size),
526    }
527}
528
529#[cfg(test)]
530mod page_tests {
531    use super::next_page_size;
532
533    #[test]
534    fn unlimited_always_requests_a_full_page() {
535        // Regression: numberOfRecords was hardcoded to 100, so folders with more
536        // than 100 documents were truncated with no indication.
537        assert_eq!(next_page_size(None, 0, 500), Some(500));
538        assert_eq!(next_page_size(None, 500, 500), Some(500));
539        assert_eq!(next_page_size(None, 100_000, 500), Some(500));
540    }
541
542    #[test]
543    fn limit_larger_than_page_size_pages_repeatedly() {
544        assert_eq!(next_page_size(Some(1200), 0, 500), Some(500));
545        assert_eq!(next_page_size(Some(1200), 500, 500), Some(500));
546        assert_eq!(next_page_size(Some(1200), 1000, 500), Some(200));
547        assert_eq!(next_page_size(Some(1200), 1200, 500), None);
548    }
549
550    #[test]
551    fn limit_smaller_than_page_size_requests_only_what_is_needed() {
552        assert_eq!(next_page_size(Some(3), 0, 500), Some(3));
553        assert_eq!(next_page_size(Some(3), 3, 500), None);
554    }
555
556    #[test]
557    fn zero_limit_fetches_nothing() {
558        assert_eq!(next_page_size(Some(0), 0, 500), None);
559    }
560
561    #[test]
562    fn overshoot_does_not_underflow() {
563        assert_eq!(next_page_size(Some(5), 9, 500), None);
564    }
565}