Skip to main content

yuki_client/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/// A general ledger account balance as of a date, from `GLAccountBalance`.
51///
52/// The operation returns every account in one response, so callers filter by
53/// `code`. `balance_type` is Yuki's `B` (balance sheet) / `W` (profit & loss)
54/// marker.
55#[derive(Debug, Clone)]
56pub struct GlAccountBalance {
57    pub code: String,
58    pub description: String,
59    pub balance_type: String,
60    pub amount: String,
61}
62
63/// Client for the Yuki Accounting SOAP service.
64pub struct AccountingClient {
65    soap: SoapClient,
66}
67
68impl AccountingClient {
69    pub fn new() -> Self {
70        Self {
71            soap: SoapClient::new(BASE_URL),
72        }
73    }
74
75    /// Build over a caller-provided HTTP client, so a long-running consumer can
76    /// share a single pooled client across all service clients.
77    pub fn with_client(http: reqwest::Client) -> Self {
78        Self {
79            soap: SoapClient::with_client(BASE_URL, http),
80        }
81    }
82
83    fn require_session(&self) -> Result<&str, YukiError> {
84        self.soap.session_id().ok_or_else(|| {
85            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
86        })
87    }
88
89    /// Authenticate with the Yuki API and store the session ID.
90    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
91        self.soap.authenticate(api_key).await
92    }
93
94    /// List all administrations accessible with the current session.
95    pub async fn administrations(&self) -> Result<Vec<Administration>, YukiError> {
96        let session = self.require_session()?;
97        let envelope = SoapEnvelope::new("Administrations")
98            .session(session)
99            .build();
100        let body = self.soap.call("Administrations", envelope).await?;
101        Self::parse_administrations(&body)
102    }
103
104    /// Set the active domain (administration) for subsequent calls.
105    pub async fn set_current_domain(&mut self, domain_id: &str) -> Result<(), YukiError> {
106        let session = self.require_session()?;
107        let envelope = SoapEnvelope::new("SetCurrentDomain")
108            .session(session)
109            .param("domainID", domain_id)
110            .build();
111        self.soap.call("SetCurrentDomain", envelope).await?;
112        Ok(())
113    }
114
115    /// Retrieve balances for all GL accounts as of a given date.
116    ///
117    /// Yuki's `GLAccountBalance` operation returns the full chart of accounts
118    /// (balance sheet and profit & loss) in one response and ignores any
119    /// account-code filter, so this returns every account; callers select the
120    /// ones they need by [`GlAccountBalance::code`].
121    pub async fn gl_account_balances(
122        &self,
123        administration_id: &str,
124        transaction_date: &str,
125    ) -> Result<Vec<GlAccountBalance>, YukiError> {
126        let session = self.require_session()?;
127        let envelope = SoapEnvelope::new("GLAccountBalance")
128            .session(session)
129            .param("administrationID", administration_id)
130            .param("transactionDate", transaction_date)
131            .build();
132        let body = self.soap.call("GLAccountBalance", envelope).await?;
133        Self::parse_gl_account_balances(&body)
134    }
135
136    /// Retrieve transactions for a GL account over a date range.
137    pub async fn gl_account_transactions(
138        &self,
139        administration_id: &str,
140        gl_account_code: &str,
141        start_date: &str,
142        end_date: &str,
143    ) -> Result<String, YukiError> {
144        let session = self.require_session()?;
145        let envelope = SoapEnvelope::new("GLAccountTransactions")
146            .session(session)
147            .param("administrationID", administration_id)
148            .param("GLAccountCode", gl_account_code)
149            .param("StartDate", start_date)
150            .param("EndDate", end_date)
151            .build();
152        self.soap.call("GLAccountTransactions", envelope).await
153    }
154
155    /// Retrieve outstanding debtor items.
156    pub async fn outstanding_debtor_items(
157        &self,
158        administration_id: &str,
159    ) -> Result<Vec<OutstandingItem>, YukiError> {
160        let session = self.require_session()?;
161        let envelope = SoapEnvelope::new("OutstandingDebtorItems")
162            .session(session)
163            .param("administrationID", administration_id)
164            .build();
165        let body = self.soap.call("OutstandingDebtorItems", envelope).await?;
166        Self::parse_outstanding_items(&body, "OutstandingDebtorItemsResult")
167    }
168
169    /// Retrieve outstanding debtor items filtered by date range.
170    pub async fn outstanding_debtor_items_by_date(
171        &self,
172        administration_id: &str,
173        start_date: &str,
174        end_date: &str,
175    ) -> Result<Vec<OutstandingItem>, YukiError> {
176        let session = self.require_session()?;
177        let envelope = SoapEnvelope::new("OutstandingDebtorItemsByDate")
178            .session(session)
179            .param("administrationID", administration_id)
180            .param("startDate", start_date)
181            .param("endDate", end_date)
182            .build();
183        let body = self
184            .soap
185            .call("OutstandingDebtorItemsByDate", envelope)
186            .await?;
187        Self::parse_outstanding_items(&body, "OutstandingDebtorItemsByDateResult")
188    }
189
190    /// Retrieve outstanding creditor items.
191    pub async fn outstanding_creditor_items(
192        &self,
193        administration_id: &str,
194    ) -> Result<Vec<OutstandingItem>, YukiError> {
195        let session = self.require_session()?;
196        let envelope = SoapEnvelope::new("OutstandingCreditorItems")
197            .session(session)
198            .param("administrationID", administration_id)
199            .build();
200        let body = self.soap.call("OutstandingCreditorItems", envelope).await?;
201        Self::parse_outstanding_items(&body, "OutstandingCreditorItemsResult")
202    }
203
204    /// Retrieve outstanding creditor items filtered by date range.
205    pub async fn outstanding_creditor_items_by_date(
206        &self,
207        administration_id: &str,
208        start_date: &str,
209        end_date: &str,
210    ) -> Result<Vec<OutstandingItem>, YukiError> {
211        let session = self.require_session()?;
212        let envelope = SoapEnvelope::new("OutstandingCreditorItemsByDate")
213            .session(session)
214            .param("administrationID", administration_id)
215            .param("startDate", start_date)
216            .param("endDate", end_date)
217            .build();
218        let body = self
219            .soap
220            .call("OutstandingCreditorItemsByDate", envelope)
221            .await?;
222        Self::parse_outstanding_items(&body, "OutstandingCreditorItemsByDateResult")
223    }
224
225    /// Retrieve transactions for a GL account with contact info over a date range.
226    pub async fn gl_account_transactions_and_contact(
227        &self,
228        administration_id: &str,
229        gl_account_code: &str,
230        start_date: &str,
231        end_date: &str,
232    ) -> Result<Vec<GlTransactionWithContact>, YukiError> {
233        let session = self.require_session()?;
234        let envelope = SoapEnvelope::new("GLAccountTransactionsAndContact")
235            .session(session)
236            .param("administrationID", administration_id)
237            .param("GLAccountCode", gl_account_code)
238            .param("StartDate", start_date)
239            .param("EndDate", end_date)
240            .build();
241        let body = self
242            .soap
243            .call("GLAccountTransactionsAndContact", envelope)
244            .await?;
245        Self::parse_gl_transactions_with_contact(&body)
246    }
247
248    /// Retrieve net revenue for a date range.
249    pub async fn net_revenue(
250        &self,
251        administration_id: &str,
252        start_date: &str,
253        end_date: &str,
254    ) -> Result<String, YukiError> {
255        let session = self.require_session()?;
256        let envelope = SoapEnvelope::new("NetRevenue")
257            .session(session)
258            .param("administrationID", administration_id)
259            .param("StartDate", start_date)
260            .param("EndDate", end_date)
261            .build();
262        let body = self.soap.call("NetRevenue", envelope).await?;
263        SoapClient::parse_single_result(&body, "NetRevenueResult")
264    }
265
266    /// Check if a specific reference is still outstanding in an administration.
267    pub async fn check_outstanding_item_admin(
268        &self,
269        administration_id: &str,
270        reference: &str,
271    ) -> Result<String, YukiError> {
272        let session = self.require_session()?;
273        let envelope = SoapEnvelope::new("CheckOutstandingItemAdmin")
274            .session(session)
275            .param("administrationID", administration_id)
276            .param("Reference", reference)
277            .build();
278        self.soap.call("CheckOutstandingItemAdmin", envelope).await
279    }
280
281    /// Parse a GLAccountBalance SOAP response into per-account balances.
282    ///
283    /// Each repeating `GLAccount` element carries `Code` and `BalanceType`
284    /// attributes and child elements `Description` and `Amount`:
285    /// `<GLAccount Code="20200" BalanceType="B"><Description>RC Ruben Jongejan</Description><Amount>3472.31</Amount></GLAccount>`
286    pub fn parse_gl_account_balances(xml: &str) -> Result<Vec<GlAccountBalance>, YukiError> {
287        if let Some(err) = SoapClient::parse_soap_fault(xml) {
288            return Err(err);
289        }
290        let mut reader = Reader::from_str(xml);
291        reader.config_mut().trim_text(true);
292
293        let mut balances = Vec::new();
294        let mut in_account = false;
295        let mut field: Option<String> = None;
296        let mut current = GlAccountBalance {
297            code: String::new(),
298            description: String::new(),
299            balance_type: String::new(),
300            amount: String::new(),
301        };
302        let mut buf = Vec::new();
303
304        loop {
305            match reader.read_event_into(&mut buf) {
306                Ok(Event::Start(ref e)) => {
307                    let local = local_name(e.name().as_ref()).to_string();
308                    match local.as_str() {
309                        "GLAccount" => {
310                            in_account = true;
311                            current = GlAccountBalance {
312                                code: String::new(),
313                                description: String::new(),
314                                balance_type: String::new(),
315                                amount: String::new(),
316                            };
317                            for attr in e.attributes().flatten() {
318                                match attr.key.as_ref() {
319                                    b"Code" => {
320                                        current.code =
321                                            String::from_utf8_lossy(&attr.value).to_string();
322                                    }
323                                    b"BalanceType" => {
324                                        current.balance_type =
325                                            String::from_utf8_lossy(&attr.value).to_string();
326                                    }
327                                    _ => {}
328                                }
329                            }
330                        }
331                        "Description" | "Amount" if in_account => {
332                            field = Some(local);
333                        }
334                        _ => {}
335                    }
336                }
337                Ok(Event::Text(ref e)) => {
338                    if let Some(ref f) = field {
339                        let text = e
340                            .unescape()
341                            .map_err(|e| YukiError::Xml(e.to_string()))?
342                            .trim()
343                            .to_string();
344                        match f.as_str() {
345                            "Description" => current.description = text,
346                            "Amount" => current.amount = text,
347                            _ => {}
348                        }
349                    }
350                }
351                Ok(Event::End(ref e)) => {
352                    let local = local_name(e.name().as_ref()).to_string();
353                    match local.as_str() {
354                        "Description" | "Amount" => field = None,
355                        "GLAccount" if in_account => {
356                            balances.push(current.clone());
357                            in_account = false;
358                        }
359                        _ => {}
360                    }
361                }
362                Ok(Event::Eof) => break,
363                Err(e) => return Err(YukiError::Xml(e.to_string())),
364                _ => {}
365            }
366            buf.clear();
367        }
368
369        Ok(balances)
370    }
371
372    /// Parse a GLAccountTransactions SOAP response into a list of `GlTransaction` values.
373    ///
374    /// Each `GLAccountTransaction` element carries an `ID` attribute and child elements
375    /// `Date`, `Description`, `Amount`, and `GLAccountCode`.
376    pub fn parse_gl_transactions(xml: &str) -> Result<Vec<GlTransaction>, YukiError> {
377        let mut reader = Reader::from_str(xml);
378        reader.config_mut().trim_text(true);
379
380        let mut transactions = Vec::new();
381        let mut in_transaction = false;
382        let mut field: Option<String> = None;
383        let mut current = GlTransaction {
384            id: String::new(),
385            date: String::new(),
386            description: String::new(),
387            gl_account: String::new(),
388            amount: String::new(),
389        };
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                        "GLAccountTransaction" => {
398                            in_transaction = true;
399                            current = GlTransaction {
400                                id: String::new(),
401                                date: String::new(),
402                                description: String::new(),
403                                gl_account: String::new(),
404                                amount: String::new(),
405                            };
406                            for attr in e.attributes().flatten() {
407                                if attr.key.as_ref() == b"ID" {
408                                    current.id = String::from_utf8_lossy(&attr.value).to_string();
409                                }
410                            }
411                        }
412                        "Date" | "Description" | "Amount" | "GLAccountCode" if in_transaction => {
413                            field = Some(local);
414                        }
415                        _ => {}
416                    }
417                }
418                Ok(Event::Text(ref e)) => {
419                    if let Some(ref f) = field {
420                        let text = e
421                            .unescape()
422                            .map_err(|e| YukiError::Xml(e.to_string()))?
423                            .trim()
424                            .to_string();
425                        match f.as_str() {
426                            "Date" => current.date = text,
427                            "Description" => current.description = text,
428                            "Amount" => current.amount = text,
429                            "GLAccountCode" => current.gl_account = text,
430                            _ => {}
431                        }
432                    }
433                }
434                Ok(Event::End(ref e)) => {
435                    let local = local_name(e.name().as_ref()).to_string();
436                    match local.as_str() {
437                        "Date" | "Description" | "Amount" | "GLAccountCode" => {
438                            field = None;
439                        }
440                        "GLAccountTransaction" if in_transaction => {
441                            transactions.push(current.clone());
442                            in_transaction = false;
443                        }
444                        _ => {}
445                    }
446                }
447                Ok(Event::Eof) => break,
448                Err(e) => return Err(YukiError::Xml(e.to_string())),
449                _ => {}
450            }
451            buf.clear();
452        }
453
454        Ok(transactions)
455    }
456
457    /// Parse a GLAccountTransactionsAndContact SOAP response.
458    ///
459    /// Like `parse_gl_transactions` but also captures `Contact`/`ContactName`.
460    pub fn parse_gl_transactions_with_contact(
461        xml: &str,
462    ) -> Result<Vec<GlTransactionWithContact>, YukiError> {
463        let mut reader = Reader::from_str(xml);
464        reader.config_mut().trim_text(true);
465
466        let mut transactions = Vec::new();
467        let mut in_transaction = false;
468        let mut field: Option<String> = None;
469        let mut current = GlTransactionWithContact {
470            id: String::new(),
471            date: String::new(),
472            description: String::new(),
473            gl_account: String::new(),
474            amount: String::new(),
475            contact_name: String::new(),
476        };
477        let mut buf = Vec::new();
478
479        loop {
480            match reader.read_event_into(&mut buf) {
481                Ok(Event::Start(ref e)) => {
482                    let local = local_name(e.name().as_ref()).to_string();
483                    match local.as_str() {
484                        "GLAccountTransaction" => {
485                            in_transaction = true;
486                            current = GlTransactionWithContact {
487                                id: String::new(),
488                                date: String::new(),
489                                description: String::new(),
490                                gl_account: String::new(),
491                                amount: String::new(),
492                                contact_name: String::new(),
493                            };
494                            for attr in e.attributes().flatten() {
495                                if attr.key.as_ref() == b"ID" {
496                                    current.id = String::from_utf8_lossy(&attr.value).to_string();
497                                }
498                            }
499                        }
500                        "Date" | "Description" | "Amount" | "GLAccountCode" | "Contact"
501                        | "ContactName"
502                            if in_transaction =>
503                        {
504                            field = Some(local);
505                        }
506                        _ => {}
507                    }
508                }
509                Ok(Event::Text(ref e)) => {
510                    if let Some(ref f) = field {
511                        let text = e
512                            .unescape()
513                            .map_err(|e| YukiError::Xml(e.to_string()))?
514                            .trim()
515                            .to_string();
516                        match f.as_str() {
517                            "Date" => current.date = text,
518                            "Description" => current.description = text,
519                            "Amount" => current.amount = text,
520                            "GLAccountCode" => current.gl_account = text,
521                            "Contact" | "ContactName" => current.contact_name = text,
522                            _ => {}
523                        }
524                    }
525                }
526                Ok(Event::End(ref e)) => {
527                    let local = local_name(e.name().as_ref()).to_string();
528                    match local.as_str() {
529                        "Date" | "Description" | "Amount" | "GLAccountCode" | "Contact"
530                        | "ContactName" => {
531                            field = None;
532                        }
533                        "GLAccountTransaction" if in_transaction => {
534                            transactions.push(current.clone());
535                            in_transaction = false;
536                        }
537                        _ => {}
538                    }
539                }
540                Ok(Event::Eof) => break,
541                Err(e) => return Err(YukiError::Xml(e.to_string())),
542                _ => {}
543            }
544            buf.clear();
545        }
546
547        Ok(transactions)
548    }
549
550    /// Parse an Administrations SOAP response into a list of `Administration` values.
551    ///
552    /// The Yuki API returns Administration elements with the ID as an XML attribute
553    /// and Name as a child element:
554    /// `<Administration ID="uuid"><Name>Company</Name>...</Administration>`
555    pub fn parse_administrations(xml: &str) -> Result<Vec<Administration>, YukiError> {
556        let mut reader = Reader::from_str(xml);
557        reader.config_mut().trim_text(true);
558
559        let mut administrations = Vec::new();
560        let mut current_id = String::new();
561        let mut current_name = String::new();
562        let mut current_domain_id = String::new();
563        let mut in_administration = false;
564        let mut in_name = false;
565        let mut in_domain_id = false;
566        let mut buf = Vec::new();
567
568        loop {
569            match reader.read_event_into(&mut buf) {
570                Ok(Event::Start(ref e)) => {
571                    let local = local_name(e.name().as_ref()).to_string();
572                    match local.as_str() {
573                        "Administration" => {
574                            in_administration = true;
575                            current_id.clear();
576                            current_name.clear();
577                            current_domain_id.clear();
578                            // ID is an attribute on the Administration element
579                            for attr in e.attributes().flatten() {
580                                if attr.key.as_ref() == b"ID" {
581                                    current_id = String::from_utf8_lossy(&attr.value).to_string();
582                                }
583                            }
584                        }
585                        "Name" if in_administration => in_name = true,
586                        "DomainID" if in_administration => in_domain_id = true,
587                        _ => {}
588                    }
589                }
590                Ok(Event::Text(ref e)) => {
591                    let text = e
592                        .unescape()
593                        .map_err(|e| YukiError::Xml(e.to_string()))?
594                        .trim()
595                        .to_string();
596                    if in_name {
597                        current_name = text;
598                    } else if in_domain_id {
599                        current_domain_id = text;
600                    }
601                }
602                Ok(Event::End(ref e)) => {
603                    let local = local_name(e.name().as_ref()).to_string();
604                    match local.as_str() {
605                        "Name" => in_name = false,
606                        "DomainID" => in_domain_id = false,
607                        "Administration" => {
608                            if !current_id.is_empty() {
609                                administrations.push(Administration {
610                                    id: current_id.clone(),
611                                    name: current_name.clone(),
612                                    domain_id: current_domain_id.clone(),
613                                });
614                            }
615                            in_administration = false;
616                        }
617                        _ => {}
618                    }
619                }
620                Ok(Event::Eof) => break,
621                Err(e) => return Err(YukiError::Xml(e.to_string())),
622                _ => {}
623            }
624            buf.clear();
625        }
626
627        Ok(administrations)
628    }
629
630    /// Parse an outstanding items SOAP response into a list of `OutstandingItem` values.
631    ///
632    /// The `result_tag` identifies the wrapper element in the response
633    /// (e.g. `"OutstandingDebtorItemsResult"`).
634    pub fn parse_outstanding_items(
635        xml: &str,
636        result_tag: &str,
637    ) -> Result<Vec<OutstandingItem>, YukiError> {
638        let mut reader = Reader::from_str(xml);
639        reader.config_mut().trim_text(true);
640
641        let mut items = Vec::new();
642        let mut in_result = false;
643        let mut in_item = false;
644        let mut field: Option<String> = None;
645        let mut current = OutstandingItem {
646            contact_name: String::new(),
647            description: String::new(),
648            date: String::new(),
649            amount: String::new(),
650            open_amount: String::new(),
651        };
652        let mut buf = Vec::new();
653
654        loop {
655            match reader.read_event_into(&mut buf) {
656                Ok(Event::Start(ref e)) => {
657                    let local = local_name(e.name().as_ref()).to_string();
658                    match local.as_str() {
659                        tag if tag == result_tag => in_result = true,
660                        "Item" if in_result => {
661                            in_item = true;
662                            current = OutstandingItem {
663                                contact_name: String::new(),
664                                description: String::new(),
665                                date: String::new(),
666                                amount: String::new(),
667                                open_amount: String::new(),
668                            };
669                        }
670                        "Contact" | "ContactName" | "Description" | "Date" | "Amount"
671                        | "OriginalAmount" | "OpenAmount"
672                            if in_item =>
673                        {
674                            field = Some(local);
675                        }
676                        _ => {}
677                    }
678                }
679                Ok(Event::Text(ref e)) => {
680                    if let Some(ref f) = field {
681                        let text = e
682                            .unescape()
683                            .map_err(|e| YukiError::Xml(e.to_string()))?
684                            .trim()
685                            .to_string();
686                        match f.as_str() {
687                            "Contact" | "ContactName" => current.contact_name = text,
688                            "Description" => current.description = text,
689                            "Date" => current.date = text,
690                            "Amount" | "OriginalAmount" => current.amount = text,
691                            "OpenAmount" => current.open_amount = text,
692                            _ => {}
693                        }
694                    }
695                }
696                Ok(Event::End(ref e)) => {
697                    let local = local_name(e.name().as_ref()).to_string();
698                    match local.as_str() {
699                        "Contact" | "ContactName" | "Description" | "Date" | "Amount"
700                        | "OriginalAmount" | "OpenAmount" => {
701                            field = None;
702                        }
703                        "Item" if in_item => {
704                            items.push(current.clone());
705                            in_item = false;
706                        }
707                        tag if tag == result_tag => {
708                            in_result = false;
709                        }
710                        _ => {}
711                    }
712                }
713                Ok(Event::Eof) => break,
714                Err(e) => return Err(YukiError::Xml(e.to_string())),
715                _ => {}
716            }
717            buf.clear();
718        }
719
720        Ok(items)
721    }
722}
723
724impl Default for AccountingClient {
725    fn default() -> Self {
726        Self::new()
727    }
728}