Skip to main content

yuki_cli/client/
accounting.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/Accounting.asmx";
10
11/// A Yuki administration (company entity).
12#[derive(Debug, Clone)]
13pub struct Administration {
14    pub id: String,
15    pub name: String,
16    pub domain_id: String,
17}
18
19/// An outstanding debtor or creditor item.
20#[derive(Debug, Clone)]
21pub struct OutstandingItem {
22    pub contact_name: String,
23    pub description: String,
24    pub date: String,
25    pub amount: String,
26    pub open_amount: String,
27}
28
29/// A general ledger transaction.
30#[derive(Debug, Clone)]
31pub struct GlTransaction {
32    pub id: String,
33    pub date: String,
34    pub description: String,
35    pub gl_account: String,
36    pub amount: String,
37}
38
39/// A general ledger transaction with contact information.
40#[derive(Debug, Clone)]
41pub struct GlTransactionWithContact {
42    pub id: String,
43    pub date: String,
44    pub description: String,
45    pub gl_account: String,
46    pub amount: String,
47    pub contact_name: String,
48}
49
50/// Client for the Yuki Accounting SOAP service.
51pub struct AccountingClient {
52    soap: SoapClient,
53}
54
55impl AccountingClient {
56    pub fn new() -> Self {
57        Self {
58            soap: SoapClient::new(BASE_URL),
59        }
60    }
61
62    fn require_session(&self) -> Result<&str, YukiError> {
63        self.soap.session_id().ok_or_else(|| {
64            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
65        })
66    }
67
68    /// Authenticate with the Yuki API and store the session ID.
69    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
70        self.soap.authenticate(api_key).await
71    }
72
73    /// List all administrations accessible with the current session.
74    pub async fn administrations(&self) -> Result<Vec<Administration>, YukiError> {
75        let session = self.require_session()?;
76        let envelope = SoapEnvelope::new("Administrations")
77            .session(session)
78            .build();
79        let body = self.soap.call("Administrations", envelope).await?;
80        Self::parse_administrations(&body)
81    }
82
83    /// Set the active domain (administration) for subsequent calls.
84    pub async fn set_current_domain(&mut self, domain_id: &str) -> Result<(), YukiError> {
85        let session = self.require_session()?;
86        let envelope = SoapEnvelope::new("SetCurrentDomain")
87            .session(session)
88            .param("domainID", domain_id)
89            .build();
90        self.soap.call("SetCurrentDomain", envelope).await?;
91        Ok(())
92    }
93
94    /// Retrieve the balance of a GL account as of a given date.
95    pub async fn gl_account_balance(
96        &self,
97        administration_id: &str,
98        gl_account_code: &str,
99        transaction_date: &str,
100    ) -> Result<String, YukiError> {
101        let session = self.require_session()?;
102        let envelope = SoapEnvelope::new("GLAccountBalance")
103            .session(session)
104            .param("administrationID", administration_id)
105            .param("GLAccountCode", gl_account_code)
106            .param("transactionDate", transaction_date)
107            .build();
108        self.soap.call("GLAccountBalance", envelope).await
109    }
110
111    /// Retrieve transactions for a GL account over a date range.
112    pub async fn gl_account_transactions(
113        &self,
114        administration_id: &str,
115        gl_account_code: &str,
116        start_date: &str,
117        end_date: &str,
118    ) -> Result<String, YukiError> {
119        let session = self.require_session()?;
120        let envelope = SoapEnvelope::new("GLAccountTransactions")
121            .session(session)
122            .param("administrationID", administration_id)
123            .param("GLAccountCode", gl_account_code)
124            .param("StartDate", start_date)
125            .param("EndDate", end_date)
126            .build();
127        self.soap.call("GLAccountTransactions", envelope).await
128    }
129
130    /// Retrieve outstanding debtor items.
131    pub async fn outstanding_debtor_items(
132        &self,
133        administration_id: &str,
134    ) -> Result<Vec<OutstandingItem>, YukiError> {
135        let session = self.require_session()?;
136        let envelope = SoapEnvelope::new("OutstandingDebtorItems")
137            .session(session)
138            .param("administrationID", administration_id)
139            .build();
140        let body = self.soap.call("OutstandingDebtorItems", envelope).await?;
141        Self::parse_outstanding_items(&body, "OutstandingDebtorItemsResult")
142    }
143
144    /// Retrieve outstanding debtor items filtered by date range.
145    pub async fn outstanding_debtor_items_by_date(
146        &self,
147        administration_id: &str,
148        start_date: &str,
149        end_date: &str,
150    ) -> Result<Vec<OutstandingItem>, YukiError> {
151        let session = self.require_session()?;
152        let envelope = SoapEnvelope::new("OutstandingDebtorItemsByDate")
153            .session(session)
154            .param("administrationID", administration_id)
155            .param("startDate", start_date)
156            .param("endDate", end_date)
157            .build();
158        let body = self
159            .soap
160            .call("OutstandingDebtorItemsByDate", envelope)
161            .await?;
162        Self::parse_outstanding_items(&body, "OutstandingDebtorItemsByDateResult")
163    }
164
165    /// Retrieve outstanding creditor items.
166    pub async fn outstanding_creditor_items(
167        &self,
168        administration_id: &str,
169    ) -> Result<Vec<OutstandingItem>, YukiError> {
170        let session = self.require_session()?;
171        let envelope = SoapEnvelope::new("OutstandingCreditorItems")
172            .session(session)
173            .param("administrationID", administration_id)
174            .build();
175        let body = self.soap.call("OutstandingCreditorItems", envelope).await?;
176        Self::parse_outstanding_items(&body, "OutstandingCreditorItemsResult")
177    }
178
179    /// Retrieve outstanding creditor items filtered by date range.
180    pub async fn outstanding_creditor_items_by_date(
181        &self,
182        administration_id: &str,
183        start_date: &str,
184        end_date: &str,
185    ) -> Result<Vec<OutstandingItem>, YukiError> {
186        let session = self.require_session()?;
187        let envelope = SoapEnvelope::new("OutstandingCreditorItemsByDate")
188            .session(session)
189            .param("administrationID", administration_id)
190            .param("startDate", start_date)
191            .param("endDate", end_date)
192            .build();
193        let body = self
194            .soap
195            .call("OutstandingCreditorItemsByDate", envelope)
196            .await?;
197        Self::parse_outstanding_items(&body, "OutstandingCreditorItemsByDateResult")
198    }
199
200    /// Retrieve transactions for a GL account with contact info over a date range.
201    pub async fn gl_account_transactions_and_contact(
202        &self,
203        administration_id: &str,
204        gl_account_code: &str,
205        start_date: &str,
206        end_date: &str,
207    ) -> Result<Vec<GlTransactionWithContact>, YukiError> {
208        let session = self.require_session()?;
209        let envelope = SoapEnvelope::new("GLAccountTransactionsAndContact")
210            .session(session)
211            .param("administrationID", administration_id)
212            .param("GLAccountCode", gl_account_code)
213            .param("StartDate", start_date)
214            .param("EndDate", end_date)
215            .build();
216        let body = self
217            .soap
218            .call("GLAccountTransactionsAndContact", envelope)
219            .await?;
220        Self::parse_gl_transactions_with_contact(&body)
221    }
222
223    /// Retrieve net revenue for a date range.
224    pub async fn net_revenue(
225        &self,
226        administration_id: &str,
227        start_date: &str,
228        end_date: &str,
229    ) -> Result<String, YukiError> {
230        let session = self.require_session()?;
231        let envelope = SoapEnvelope::new("NetRevenue")
232            .session(session)
233            .param("administrationID", administration_id)
234            .param("StartDate", start_date)
235            .param("EndDate", end_date)
236            .build();
237        let body = self.soap.call("NetRevenue", envelope).await?;
238        SoapClient::parse_single_result(&body, "NetRevenueResult")
239    }
240
241    /// Check if a specific reference is still outstanding in an administration.
242    pub async fn check_outstanding_item_admin(
243        &self,
244        administration_id: &str,
245        reference: &str,
246    ) -> Result<String, YukiError> {
247        let session = self.require_session()?;
248        let envelope = SoapEnvelope::new("CheckOutstandingItemAdmin")
249            .session(session)
250            .param("administrationID", administration_id)
251            .param("Reference", reference)
252            .build();
253        self.soap.call("CheckOutstandingItemAdmin", envelope).await
254    }
255
256    /// Parse a GLAccountTransactions SOAP response into a list of `GlTransaction` values.
257    ///
258    /// Each `GLAccountTransaction` element carries an `ID` attribute and child elements
259    /// `Date`, `Description`, `Amount`, and `GLAccountCode`.
260    pub fn parse_gl_transactions(xml: &str) -> Result<Vec<GlTransaction>, YukiError> {
261        let mut reader = Reader::from_str(xml);
262        reader.config_mut().trim_text(true);
263
264        let mut transactions = Vec::new();
265        let mut in_transaction = false;
266        let mut field: Option<String> = None;
267        let mut current = GlTransaction {
268            id: String::new(),
269            date: String::new(),
270            description: String::new(),
271            gl_account: String::new(),
272            amount: String::new(),
273        };
274        let mut buf = Vec::new();
275
276        loop {
277            match reader.read_event_into(&mut buf) {
278                Ok(Event::Start(ref e)) => {
279                    let local = local_name(e.name().as_ref()).to_string();
280                    match local.as_str() {
281                        "GLAccountTransaction" => {
282                            in_transaction = true;
283                            current = GlTransaction {
284                                id: String::new(),
285                                date: String::new(),
286                                description: String::new(),
287                                gl_account: String::new(),
288                                amount: String::new(),
289                            };
290                            for attr in e.attributes().flatten() {
291                                if attr.key.as_ref() == b"ID" {
292                                    current.id = String::from_utf8_lossy(&attr.value).to_string();
293                                }
294                            }
295                        }
296                        "Date" | "Description" | "Amount" | "GLAccountCode" if in_transaction => {
297                            field = Some(local);
298                        }
299                        _ => {}
300                    }
301                }
302                Ok(Event::Text(ref e)) => {
303                    if let Some(ref f) = field {
304                        let text = e
305                            .unescape()
306                            .map_err(|e| YukiError::Xml(e.to_string()))?
307                            .trim()
308                            .to_string();
309                        match f.as_str() {
310                            "Date" => current.date = text,
311                            "Description" => current.description = text,
312                            "Amount" => current.amount = text,
313                            "GLAccountCode" => current.gl_account = text,
314                            _ => {}
315                        }
316                    }
317                }
318                Ok(Event::End(ref e)) => {
319                    let local = local_name(e.name().as_ref()).to_string();
320                    match local.as_str() {
321                        "Date" | "Description" | "Amount" | "GLAccountCode" => {
322                            field = None;
323                        }
324                        "GLAccountTransaction" if in_transaction => {
325                            transactions.push(current.clone());
326                            in_transaction = false;
327                        }
328                        _ => {}
329                    }
330                }
331                Ok(Event::Eof) => break,
332                Err(e) => return Err(YukiError::Xml(e.to_string())),
333                _ => {}
334            }
335            buf.clear();
336        }
337
338        Ok(transactions)
339    }
340
341    /// Parse a GLAccountTransactionsAndContact SOAP response.
342    ///
343    /// Like `parse_gl_transactions` but also captures `Contact`/`ContactName`.
344    pub fn parse_gl_transactions_with_contact(
345        xml: &str,
346    ) -> Result<Vec<GlTransactionWithContact>, YukiError> {
347        let mut reader = Reader::from_str(xml);
348        reader.config_mut().trim_text(true);
349
350        let mut transactions = Vec::new();
351        let mut in_transaction = false;
352        let mut field: Option<String> = None;
353        let mut current = GlTransactionWithContact {
354            id: String::new(),
355            date: String::new(),
356            description: String::new(),
357            gl_account: String::new(),
358            amount: String::new(),
359            contact_name: String::new(),
360        };
361        let mut buf = Vec::new();
362
363        loop {
364            match reader.read_event_into(&mut buf) {
365                Ok(Event::Start(ref e)) => {
366                    let local = local_name(e.name().as_ref()).to_string();
367                    match local.as_str() {
368                        "GLAccountTransaction" => {
369                            in_transaction = true;
370                            current = GlTransactionWithContact {
371                                id: String::new(),
372                                date: String::new(),
373                                description: String::new(),
374                                gl_account: String::new(),
375                                amount: String::new(),
376                                contact_name: String::new(),
377                            };
378                            for attr in e.attributes().flatten() {
379                                if attr.key.as_ref() == b"ID" {
380                                    current.id = String::from_utf8_lossy(&attr.value).to_string();
381                                }
382                            }
383                        }
384                        "Date" | "Description" | "Amount" | "GLAccountCode" | "Contact"
385                        | "ContactName"
386                            if in_transaction =>
387                        {
388                            field = Some(local);
389                        }
390                        _ => {}
391                    }
392                }
393                Ok(Event::Text(ref e)) => {
394                    if let Some(ref f) = field {
395                        let text = e
396                            .unescape()
397                            .map_err(|e| YukiError::Xml(e.to_string()))?
398                            .trim()
399                            .to_string();
400                        match f.as_str() {
401                            "Date" => current.date = text,
402                            "Description" => current.description = text,
403                            "Amount" => current.amount = text,
404                            "GLAccountCode" => current.gl_account = text,
405                            "Contact" | "ContactName" => current.contact_name = text,
406                            _ => {}
407                        }
408                    }
409                }
410                Ok(Event::End(ref e)) => {
411                    let local = local_name(e.name().as_ref()).to_string();
412                    match local.as_str() {
413                        "Date" | "Description" | "Amount" | "GLAccountCode" | "Contact"
414                        | "ContactName" => {
415                            field = None;
416                        }
417                        "GLAccountTransaction" if in_transaction => {
418                            transactions.push(current.clone());
419                            in_transaction = 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(transactions)
432    }
433
434    /// Parse an Administrations SOAP response into a list of `Administration` values.
435    ///
436    /// The Yuki API returns Administration elements with the ID as an XML attribute
437    /// and Name as a child element:
438    /// `<Administration ID="uuid"><Name>Company</Name>...</Administration>`
439    pub fn parse_administrations(xml: &str) -> Result<Vec<Administration>, YukiError> {
440        let mut reader = Reader::from_str(xml);
441        reader.config_mut().trim_text(true);
442
443        let mut administrations = Vec::new();
444        let mut current_id = String::new();
445        let mut current_name = String::new();
446        let mut current_domain_id = String::new();
447        let mut in_administration = false;
448        let mut in_name = false;
449        let mut in_domain_id = false;
450        let mut buf = Vec::new();
451
452        loop {
453            match reader.read_event_into(&mut buf) {
454                Ok(Event::Start(ref e)) => {
455                    let local = local_name(e.name().as_ref()).to_string();
456                    match local.as_str() {
457                        "Administration" => {
458                            in_administration = true;
459                            current_id.clear();
460                            current_name.clear();
461                            current_domain_id.clear();
462                            // ID is an attribute on the Administration element
463                            for attr in e.attributes().flatten() {
464                                if attr.key.as_ref() == b"ID" {
465                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
466                                }
467                            }
468                        }
469                        "Name" if in_administration => in_name = true,
470                        "DomainID" if in_administration => in_domain_id = true,
471                        _ => {}
472                    }
473                }
474                Ok(Event::Text(ref e)) => {
475                    let text = e
476                        .unescape()
477                        .map_err(|e| YukiError::Xml(e.to_string()))?
478                        .trim()
479                        .to_string();
480                    if in_name {
481                        current_name = text;
482                    } else if in_domain_id {
483                        current_domain_id = text;
484                    }
485                }
486                Ok(Event::End(ref e)) => {
487                    let local = local_name(e.name().as_ref()).to_string();
488                    match local.as_str() {
489                        "Name" => in_name = false,
490                        "DomainID" => in_domain_id = false,
491                        "Administration" => {
492                            if !current_id.is_empty() {
493                                administrations.push(Administration {
494                                    id: current_id.clone(),
495                                    name: current_name.clone(),
496                                    domain_id: current_domain_id.clone(),
497                                });
498                            }
499                            in_administration = false;
500                        }
501                        _ => {}
502                    }
503                }
504                Ok(Event::Eof) => break,
505                Err(e) => return Err(YukiError::Xml(e.to_string())),
506                _ => {}
507            }
508            buf.clear();
509        }
510
511        Ok(administrations)
512    }
513
514    /// Parse an outstanding items SOAP response into a list of `OutstandingItem` values.
515    ///
516    /// The `result_tag` identifies the wrapper element in the response
517    /// (e.g. `"OutstandingDebtorItemsResult"`).
518    pub fn parse_outstanding_items(
519        xml: &str,
520        result_tag: &str,
521    ) -> Result<Vec<OutstandingItem>, YukiError> {
522        let mut reader = Reader::from_str(xml);
523        reader.config_mut().trim_text(true);
524
525        let mut items = Vec::new();
526        let mut in_result = false;
527        let mut in_item = false;
528        let mut field: Option<String> = None;
529        let mut current = OutstandingItem {
530            contact_name: String::new(),
531            description: String::new(),
532            date: String::new(),
533            amount: String::new(),
534            open_amount: String::new(),
535        };
536        let mut buf = Vec::new();
537
538        loop {
539            match reader.read_event_into(&mut buf) {
540                Ok(Event::Start(ref e)) => {
541                    let local = local_name(e.name().as_ref()).to_string();
542                    match local.as_str() {
543                        tag if tag == result_tag => in_result = true,
544                        "Item" if in_result => {
545                            in_item = true;
546                            current = OutstandingItem {
547                                contact_name: String::new(),
548                                description: String::new(),
549                                date: String::new(),
550                                amount: String::new(),
551                                open_amount: String::new(),
552                            };
553                        }
554                        "Contact" | "ContactName" | "Description" | "Date" | "Amount"
555                        | "OriginalAmount" | "OpenAmount"
556                            if in_item =>
557                        {
558                            field = Some(local);
559                        }
560                        _ => {}
561                    }
562                }
563                Ok(Event::Text(ref e)) => {
564                    if let Some(ref f) = field {
565                        let text = e
566                            .unescape()
567                            .map_err(|e| YukiError::Xml(e.to_string()))?
568                            .trim()
569                            .to_string();
570                        match f.as_str() {
571                            "Contact" | "ContactName" => current.contact_name = text,
572                            "Description" => current.description = text,
573                            "Date" => current.date = text,
574                            "Amount" | "OriginalAmount" => current.amount = text,
575                            "OpenAmount" => current.open_amount = text,
576                            _ => {}
577                        }
578                    }
579                }
580                Ok(Event::End(ref e)) => {
581                    let local = local_name(e.name().as_ref()).to_string();
582                    match local.as_str() {
583                        "Contact" | "ContactName" | "Description" | "Date" | "Amount"
584                        | "OriginalAmount" | "OpenAmount" => {
585                            field = None;
586                        }
587                        "Item" if in_item => {
588                            items.push(current.clone());
589                            in_item = false;
590                        }
591                        tag if tag == result_tag => {
592                            in_result = false;
593                        }
594                        _ => {}
595                    }
596                }
597                Ok(Event::Eof) => break,
598                Err(e) => return Err(YukiError::Xml(e.to_string())),
599                _ => {}
600            }
601            buf.clear();
602        }
603
604        Ok(items)
605    }
606}
607
608impl Default for AccountingClient {
609    fn default() -> Self {
610        Self::new()
611    }
612}