Skip to main content

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