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#[derive(Debug, Clone)]
13pub struct CostCategory {
14 pub id: String,
15 pub description: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct PaymentMethod {
21 pub id: String,
22 pub description: String,
23}
24
25#[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
38pub 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 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 pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
66 self.soap.authenticate(api_key).await
67 }
68
69 const FOLDER_PAGE_SIZE: usize = 500;
71
72 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 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 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 if received < want {
131 break;
132 }
133 start_record += received;
134 }
135
136 Ok(collected)
137 }
138
139 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 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 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 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 #[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 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 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 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 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 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
511pub(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 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}