Skip to main content

yuki_client/client/
accounting_info.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/AccountingInfo.asmx";
10
11/// A general ledger account from the chart of accounts.
12#[derive(Debug, Clone)]
13pub struct GlAccount {
14    pub code: String,
15    pub description: String,
16    pub account_type: String,
17}
18
19/// Opening balance for a GL account.
20#[derive(Debug, Clone)]
21pub struct AccountStartBalance {
22    pub gl_account_code: String,
23    pub description: String,
24    pub balance: String,
25}
26
27/// A project entry.
28#[derive(Debug, Clone)]
29pub struct Project {
30    pub id: String,
31    pub code: String,
32    pub description: String,
33}
34
35/// A project balance entry.
36#[derive(Debug, Clone)]
37pub struct ProjectBalance {
38    pub project_code: String,
39    pub gl_account_code: String,
40    pub amount: String,
41}
42
43/// Full details for a single transaction line.
44#[derive(Debug, Clone)]
45pub struct TransactionDetail {
46    pub id: String,
47    pub date: String,
48    pub description: String,
49    pub amount: String,
50    pub currency: String,
51    pub gl_account_code: String,
52}
53
54/// Client for the Yuki AccountingInfo SOAP service.
55pub struct AccountingInfoClient {
56    soap: SoapClient,
57}
58
59impl AccountingInfoClient {
60    pub fn new() -> Self {
61        Self {
62            soap: SoapClient::new(BASE_URL),
63        }
64    }
65
66    /// Build over a caller-provided HTTP client, so a long-running consumer can
67    /// share a single pooled client across all service clients.
68    pub fn with_client(http: reqwest::Client) -> Self {
69        Self {
70            soap: SoapClient::with_client(BASE_URL, http),
71        }
72    }
73
74    fn require_session(&self) -> Result<&str, YukiError> {
75        self.soap.session_id().ok_or_else(|| {
76            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
77        })
78    }
79
80    /// Authenticate with the Yuki API and store the session ID.
81    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
82        self.soap.authenticate(api_key).await
83    }
84
85    /// Retrieve full details for a single transaction by ID.
86    pub async fn get_transaction_details(
87        &self,
88        transaction_id: &str,
89    ) -> Result<Vec<TransactionDetail>, YukiError> {
90        let session = self.require_session()?;
91        let envelope = SoapEnvelope::new("GetTransactionDetails")
92            .session(session)
93            .param("transactionId", transaction_id)
94            .build();
95        let body = self.soap.call("GetTransactionDetails", envelope).await?;
96        Self::parse_transaction_details(&body)
97    }
98
99    /// Parse a GetTransactionDetails SOAP response into a list of `TransactionDetail` values.
100    ///
101    /// Each `TransactionInfo` element carries child elements `id`, `transactionDate`,
102    /// `description`, `transactionAmount`, `currency`, and `glAccountCode`.
103    pub fn parse_transaction_details(xml: &str) -> Result<Vec<TransactionDetail>, YukiError> {
104        let mut reader = Reader::from_str(xml);
105        reader.config_mut().trim_text(true);
106
107        let mut details = Vec::new();
108        let mut in_info = false;
109        let mut field: Option<String> = None;
110        let mut current = TransactionDetail {
111            id: String::new(),
112            date: String::new(),
113            description: String::new(),
114            amount: String::new(),
115            currency: String::new(),
116            gl_account_code: String::new(),
117        };
118        let mut buf = Vec::new();
119
120        loop {
121            match reader.read_event_into(&mut buf) {
122                Ok(Event::Start(ref e)) => {
123                    let local = local_name(e.name().as_ref()).to_string();
124                    match local.as_str() {
125                        "TransactionInfo" => {
126                            in_info = true;
127                            current = TransactionDetail {
128                                id: String::new(),
129                                date: String::new(),
130                                description: String::new(),
131                                amount: String::new(),
132                                currency: String::new(),
133                                gl_account_code: String::new(),
134                            };
135                        }
136                        "id" | "transactionDate" | "description" | "transactionAmount"
137                        | "currency" | "glAccountCode"
138                            if in_info =>
139                        {
140                            field = Some(local);
141                        }
142                        _ => {}
143                    }
144                }
145                Ok(Event::Text(ref e)) => {
146                    if let Some(ref f) = field {
147                        let text = e
148                            .unescape()
149                            .map_err(|e| YukiError::Xml(e.to_string()))?
150                            .trim()
151                            .to_string();
152                        match f.as_str() {
153                            "id" => current.id = text,
154                            "transactionDate" => current.date = text,
155                            "description" => current.description = text,
156                            "transactionAmount" => current.amount = text,
157                            "currency" => current.currency = text,
158                            "glAccountCode" => current.gl_account_code = text,
159                            _ => {}
160                        }
161                    }
162                }
163                Ok(Event::End(ref e)) => {
164                    let local = local_name(e.name().as_ref()).to_string();
165                    match local.as_str() {
166                        "id" | "transactionDate" | "description" | "transactionAmount"
167                        | "currency" | "glAccountCode" => {
168                            field = None;
169                        }
170                        "TransactionInfo" if in_info => {
171                            details.push(current.clone());
172                            in_info = false;
173                        }
174                        _ => {}
175                    }
176                }
177                Ok(Event::Eof) => break,
178                Err(e) => return Err(YukiError::Xml(e.to_string())),
179                _ => {}
180            }
181            buf.clear();
182        }
183
184        Ok(details)
185    }
186
187    /// Retrieve the full GL account scheme (chart of accounts).
188    pub async fn get_gl_account_scheme(
189        &self,
190        administration_id: &str,
191    ) -> Result<Vec<GlAccount>, YukiError> {
192        let session = self.require_session()?;
193        let envelope = SoapEnvelope::new("GetGLAccountScheme")
194            .session(session)
195            .param("administrationID", administration_id)
196            .build();
197        let body = self.soap.call("GetGLAccountScheme", envelope).await?;
198        Self::parse_gl_accounts(&body)
199    }
200
201    /// Retrieve the document linked to a transaction.
202    pub async fn get_transaction_document(
203        &self,
204        administration_id: &str,
205        transaction_id: &str,
206    ) -> Result<String, YukiError> {
207        let session = self.require_session()?;
208        let envelope = SoapEnvelope::new("GetTransactionDocument")
209            .session(session)
210            .param("administrationID", administration_id)
211            .param("transactionID", transaction_id)
212            .build();
213        self.soap.call("GetTransactionDocument", envelope).await
214    }
215
216    /// Retrieve the period date table for a given fiscal year.
217    pub async fn get_period_date_table(&self, year: &str) -> Result<String, YukiError> {
218        let session = self.require_session()?;
219        let envelope = SoapEnvelope::new("GetPeriodDateTable")
220            .session(session)
221            .param("year", year)
222            .build();
223        self.soap.call("GetPeriodDateTable", envelope).await
224    }
225
226    /// Retrieve opening balances per GL account for a book year.
227    pub async fn get_start_balance_by_gl_account(
228        &self,
229        administration_id: &str,
230        bookyear: &str,
231    ) -> Result<Vec<AccountStartBalance>, YukiError> {
232        let session = self.require_session()?;
233        let envelope = SoapEnvelope::new("GetStartBalanceByGlAccount")
234            .session(session)
235            .param("administrationID", administration_id)
236            .param("bookyear", bookyear)
237            .param("financialMode", "0")
238            .build();
239        let body = self
240            .soap
241            .call("GetStartBalanceByGlAccount", envelope)
242            .await?;
243        Self::parse_start_balances(&body)
244    }
245
246    /// Retrieve all projects.
247    pub async fn get_projects(&self, administration_id: &str) -> Result<Vec<Project>, YukiError> {
248        let session = self.require_session()?;
249        let envelope = SoapEnvelope::new("GetProjectsAndID")
250            .session(session)
251            .param("administrationID", administration_id)
252            .param("searchOption", "")
253            .param("searchValue", "")
254            .build();
255        let body = self.soap.call("GetProjectsAndID", envelope).await?;
256        Self::parse_projects(&body)
257    }
258
259    /// Retrieve project balance for a specific project and GL account over a date range.
260    pub async fn get_project_balance(
261        &self,
262        administration_id: &str,
263        project_code: &str,
264        gl_account_code: &str,
265        start_date: &str,
266        end_date: &str,
267    ) -> Result<Vec<ProjectBalance>, YukiError> {
268        let session = self.require_session()?;
269        let envelope = SoapEnvelope::new("GetProjectBalance")
270            .session(session)
271            .param("administrationID", administration_id)
272            .param("GLAccountCode", gl_account_code)
273            .param("projectCode", project_code)
274            .param("StartDate", start_date)
275            .param("EndDate", end_date)
276            .build();
277        let body = self.soap.call("GetProjectBalance", envelope).await?;
278        Self::parse_project_balances(&body)
279    }
280
281    /// Parse a GetGLAccountScheme response into a list of `GlAccount` values.
282    ///
283    /// Each `GlAccount` element carries child elements for code, description, and type.
284    pub fn parse_gl_accounts(xml: &str) -> Result<Vec<GlAccount>, YukiError> {
285        let mut reader = Reader::from_str(xml);
286        reader.config_mut().trim_text(true);
287
288        let mut accounts = Vec::new();
289        let mut in_account = false;
290        let mut field: Option<String> = None;
291        let mut current = GlAccount {
292            code: String::new(),
293            description: String::new(),
294            account_type: String::new(),
295        };
296        let mut buf = Vec::new();
297
298        loop {
299            match reader.read_event_into(&mut buf) {
300                Ok(Event::Start(ref e)) => {
301                    let local = local_name(e.name().as_ref()).to_string();
302                    match local.as_str() {
303                        "GlAccount" | "GLAccount" => {
304                            in_account = true;
305                            current = GlAccount {
306                                code: String::new(),
307                                description: String::new(),
308                                account_type: String::new(),
309                            };
310                        }
311                        "Code" | "code" | "Description" | "description" | "descripton" | "Type"
312                        | "type"
313                            if in_account =>
314                        {
315                            field = Some(local);
316                        }
317                        _ => {}
318                    }
319                }
320                Ok(Event::Text(ref e)) => {
321                    if let Some(ref f) = field {
322                        let text = e
323                            .unescape()
324                            .map_err(|e| YukiError::Xml(e.to_string()))?
325                            .trim()
326                            .to_string();
327                        match f.as_str() {
328                            "Code" | "code" => current.code = text,
329                            "Description" | "description" | "descripton" => {
330                                current.description = text;
331                            }
332                            "Type" | "type" => current.account_type = text,
333                            _ => {}
334                        }
335                    }
336                }
337                Ok(Event::End(ref e)) => {
338                    let local = local_name(e.name().as_ref()).to_string();
339                    match local.as_str() {
340                        "Code" | "code" | "Description" | "description" | "descripton" | "Type"
341                        | "type" => {
342                            field = None;
343                        }
344                        "GlAccount" | "GLAccount" if in_account => {
345                            accounts.push(current.clone());
346                            in_account = false;
347                        }
348                        _ => {}
349                    }
350                }
351                Ok(Event::Eof) => break,
352                Err(e) => return Err(YukiError::Xml(e.to_string())),
353                _ => {}
354            }
355            buf.clear();
356        }
357
358        Ok(accounts)
359    }
360
361    pub fn parse_start_balances(xml: &str) -> Result<Vec<AccountStartBalance>, YukiError> {
362        let mut reader = Reader::from_str(xml);
363        reader.config_mut().trim_text(true);
364
365        let mut balances = Vec::new();
366        let mut in_item = false;
367        let mut field: Option<String> = None;
368        let mut current = AccountStartBalance {
369            gl_account_code: String::new(),
370            description: String::new(),
371            balance: String::new(),
372        };
373        let mut buf = Vec::new();
374
375        loop {
376            match reader.read_event_into(&mut buf) {
377                Ok(Event::Start(ref e)) => {
378                    let local = local_name(e.name().as_ref()).to_string();
379                    match local.as_str() {
380                        "AccountStartBalance" => {
381                            in_item = true;
382                            current = AccountStartBalance {
383                                gl_account_code: String::new(),
384                                description: String::new(),
385                                balance: String::new(),
386                            };
387                        }
388                        "GLAccountCode" | "glAccountCode" | "accountID" | "Description"
389                        | "description" | "accountDescription" | "Balance" | "balance"
390                        | "StartBalance" | "startBalance"
391                            if in_item =>
392                        {
393                            field = Some(local);
394                        }
395                        _ => {}
396                    }
397                }
398                Ok(Event::Text(ref e)) => {
399                    if let Some(ref f) = field {
400                        let text = e
401                            .unescape()
402                            .map_err(|e| YukiError::Xml(e.to_string()))?
403                            .trim()
404                            .to_string();
405                        match f.as_str() {
406                            "GLAccountCode" | "glAccountCode" | "accountID" => {
407                                current.gl_account_code = text;
408                            }
409                            "Description" | "description" | "accountDescription" => {
410                                current.description = text;
411                            }
412                            "Balance" | "balance" | "StartBalance" | "startBalance" => {
413                                current.balance = text;
414                            }
415                            _ => {}
416                        }
417                    }
418                }
419                Ok(Event::End(ref e)) => {
420                    let local = local_name(e.name().as_ref()).to_string();
421                    match local.as_str() {
422                        "GLAccountCode" | "glAccountCode" | "accountID" | "Description"
423                        | "description" | "accountDescription" | "Balance" | "balance"
424                        | "StartBalance" | "startBalance" => {
425                            field = None;
426                        }
427                        "AccountStartBalance" if in_item => {
428                            balances.push(current.clone());
429                            in_item = false;
430                        }
431                        _ => {}
432                    }
433                }
434                Ok(Event::Eof) => break,
435                Err(e) => return Err(YukiError::Xml(e.to_string())),
436                _ => {}
437            }
438            buf.clear();
439        }
440
441        Ok(balances)
442    }
443
444    fn parse_projects(xml: &str) -> Result<Vec<Project>, YukiError> {
445        let mut reader = Reader::from_str(xml);
446        reader.config_mut().trim_text(true);
447
448        let mut projects = Vec::new();
449        let mut in_item = false;
450        let mut field: Option<String> = None;
451        let mut current = Project {
452            id: String::new(),
453            code: String::new(),
454            description: String::new(),
455        };
456        let mut buf = Vec::new();
457
458        loop {
459            match reader.read_event_into(&mut buf) {
460                Ok(Event::Start(ref e)) => {
461                    let local = local_name(e.name().as_ref()).to_string();
462                    match local.as_str() {
463                        "Project" => {
464                            in_item = true;
465                            current = Project {
466                                id: String::new(),
467                                code: String::new(),
468                                description: String::new(),
469                            };
470                            for attr in e.attributes().flatten() {
471                                if attr.key.as_ref() == b"ID" {
472                                    current.id = String::from_utf8_lossy(&attr.value).to_string();
473                                }
474                            }
475                        }
476                        "Code" | "code" | "Description" | "description" if in_item => {
477                            field = Some(local);
478                        }
479                        _ => {}
480                    }
481                }
482                Ok(Event::Text(ref e)) => {
483                    if let Some(ref f) = field {
484                        let text = e
485                            .unescape()
486                            .map_err(|e| YukiError::Xml(e.to_string()))?
487                            .trim()
488                            .to_string();
489                        match f.as_str() {
490                            "Code" | "code" => current.code = text,
491                            "Description" | "description" => current.description = text,
492                            _ => {}
493                        }
494                    }
495                }
496                Ok(Event::End(ref e)) => {
497                    let local = local_name(e.name().as_ref()).to_string();
498                    match local.as_str() {
499                        "Code" | "code" | "Description" | "description" => {
500                            field = None;
501                        }
502                        "Project" if in_item => {
503                            projects.push(current.clone());
504                            in_item = false;
505                        }
506                        _ => {}
507                    }
508                }
509                Ok(Event::Eof) => break,
510                Err(e) => return Err(YukiError::Xml(e.to_string())),
511                _ => {}
512            }
513            buf.clear();
514        }
515
516        Ok(projects)
517    }
518
519    fn parse_project_balances(xml: &str) -> Result<Vec<ProjectBalance>, YukiError> {
520        let mut reader = Reader::from_str(xml);
521        reader.config_mut().trim_text(true);
522
523        let mut balances = Vec::new();
524        let mut in_item = false;
525        let mut field: Option<String> = None;
526        let mut current = ProjectBalance {
527            project_code: String::new(),
528            gl_account_code: String::new(),
529            amount: String::new(),
530        };
531        let mut buf = Vec::new();
532
533        loop {
534            match reader.read_event_into(&mut buf) {
535                Ok(Event::Start(ref e)) => {
536                    let local = local_name(e.name().as_ref()).to_string();
537                    match local.as_str() {
538                        "ProjectBalance" => {
539                            in_item = true;
540                            current = ProjectBalance {
541                                project_code: String::new(),
542                                gl_account_code: String::new(),
543                                amount: String::new(),
544                            };
545                        }
546                        "ProjectCode" | "projectCode" | "GLAccountCode" | "glAccountCode"
547                        | "Amount" | "amount"
548                            if in_item =>
549                        {
550                            field = Some(local);
551                        }
552                        _ => {}
553                    }
554                }
555                Ok(Event::Text(ref e)) => {
556                    if let Some(ref f) = field {
557                        let text = e
558                            .unescape()
559                            .map_err(|e| YukiError::Xml(e.to_string()))?
560                            .trim()
561                            .to_string();
562                        match f.as_str() {
563                            "ProjectCode" | "projectCode" => current.project_code = text,
564                            "GLAccountCode" | "glAccountCode" => current.gl_account_code = text,
565                            "Amount" | "amount" => current.amount = text,
566                            _ => {}
567                        }
568                    }
569                }
570                Ok(Event::End(ref e)) => {
571                    let local = local_name(e.name().as_ref()).to_string();
572                    match local.as_str() {
573                        "ProjectCode" | "projectCode" | "GLAccountCode" | "glAccountCode"
574                        | "Amount" | "amount" => {
575                            field = None;
576                        }
577                        "ProjectBalance" if in_item => {
578                            balances.push(current.clone());
579                            in_item = false;
580                        }
581                        _ => {}
582                    }
583                }
584                Ok(Event::Eof) => break,
585                Err(e) => return Err(YukiError::Xml(e.to_string())),
586                _ => {}
587            }
588            buf.clear();
589        }
590
591        Ok(balances)
592    }
593}
594
595impl Default for AccountingInfoClient {
596    fn default() -> Self {
597        Self::new()
598    }
599}