Skip to main content

ofx_rs/
parser.rs

1use quick_xml::events::Event;
2
3use crate::aggregates::{
4    AvailableBalance, Balance, BankAccount, CcStatementResponse, CreditCardAccount, CurrencyInfo,
5    InvStatementResponse, InvestmentAccount, LedgerBalance, Payee, StatementResponse,
6    StatementTransactionBuilder, Status, TransactionList, TransactionWrapper,
7};
8use crate::document::{
9    BankingMessageSet, CreditCardMessageSet, InvestmentMessageSet, OfxDocument, SignonResponse,
10};
11use crate::error::OfxError;
12use crate::header::{self, OfxHeader};
13use crate::xml::{OfxReader, XmlError};
14
15/// Parses a complete OFX document from a string.
16///
17/// This is the primary entry point for the library. It accepts the full
18/// content of an OFX file (header + XML body) and returns a structured document.
19///
20/// # Errors
21///
22/// Returns `OfxError` if the header is missing or malformed, the XML is invalid,
23/// or required OFX elements are missing.
24pub fn parse(input: &str) -> Result<OfxDocument, OfxError> {
25    let (ofx_header, xml_body) = header::parse_header(input)?;
26    let xml_body = xml_body.trim();
27
28    // OFX 1.x uses SGML where closing tags are optional. We need to normalize
29    // the body to well-formed XML before parsing.
30    if ofx_header.version().major() < 2 {
31        let normalized = crate::sgml::normalize_sgml_to_xml(xml_body);
32        parse_ofx_body(&normalized, ofx_header)
33    } else {
34        parse_ofx_body(xml_body, ofx_header)
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Tag name helpers -- compare against byte slices to avoid allocations
40// ---------------------------------------------------------------------------
41
42fn tag_name(e: &quick_xml::events::BytesStart<'_>) -> String {
43    String::from_utf8_lossy(e.name().as_ref()).into_owned()
44}
45
46fn end_tag_name(e: &quick_xml::events::BytesEnd<'_>) -> String {
47    String::from_utf8_lossy(e.name().as_ref()).into_owned()
48}
49
50/// Read the text content of the current element (after Start has been consumed).
51fn read_text(reader: &mut OfxReader<'_>, tag: &str) -> Result<String, XmlError> {
52    reader.read_text(tag)
53}
54
55/// Read a required text element by name inside an aggregate being walked.
56/// This is for use when we've already received a Start event for `tag`.
57fn parse_text_as<T: core::str::FromStr>(
58    text: &str,
59    element: &str,
60) -> Result<T, XmlError> {
61    text.parse::<T>().map_err(|_| XmlError::InvalidContent {
62        element: element.to_owned(),
63        value: text.to_owned(),
64        reason: format!("failed to parse as {}", core::any::type_name::<T>()),
65    })
66}
67
68// ---------------------------------------------------------------------------
69// Top-level document parsing
70// ---------------------------------------------------------------------------
71
72fn parse_ofx_body(xml: &str, header: OfxHeader) -> Result<OfxDocument, OfxError> {
73    let mut reader = OfxReader::new(xml);
74    let mut signon: Option<SignonResponse> = None;
75    let mut banking: Option<BankingMessageSet> = None;
76    let mut credit_card: Option<CreditCardMessageSet> = None;
77    let mut investment: Option<InvestmentMessageSet> = None;
78
79    // Find and enter the <OFX> root element
80    loop {
81        match reader.next_event()? {
82            Event::Start(e) if tag_name(&e) == "OFX" => break,
83            Event::Eof => {
84                return Err(XmlError::MissingElement {
85                    parent: "document".to_owned(),
86                    element: "OFX".to_owned(),
87                }
88                .into());
89            }
90            _ => {}
91        }
92    }
93
94    // Parse children of <OFX>
95    loop {
96        match reader.next_event()? {
97            Event::Start(e) => {
98                let name = tag_name(&e);
99                match name.as_str() {
100                    "SIGNONMSGSRSV1" => {
101                        signon = Some(parse_signon_message_set(&mut reader)?);
102                    }
103                    "BANKMSGSRSV1" => {
104                        banking = Some(parse_banking_message_set(&mut reader)?);
105                    }
106                    "CREDITCARDMSGSRSV1" => {
107                        credit_card = Some(parse_cc_message_set(&mut reader)?);
108                    }
109                    "INVSTMTMSGSRSV1" => {
110                        investment = Some(parse_investment_message_set(&mut reader)?);
111                    }
112                    _ => {
113                        reader.skip_element(&name)?;
114                    }
115                }
116            }
117            Event::End(e) if end_tag_name(&e) == "OFX" => break,
118            Event::Eof => break,
119            _ => {}
120        }
121    }
122
123    let signon = signon.ok_or_else(|| XmlError::MissingElement {
124        parent: "OFX".to_owned(),
125        element: "SIGNONMSGSRSV1".to_owned(),
126    })?;
127
128    let mut doc = OfxDocument::new(header, signon);
129    if let Some(b) = banking {
130        doc = doc.with_banking(b);
131    }
132    if let Some(cc) = credit_card {
133        doc = doc.with_credit_card(cc);
134    }
135    if let Some(inv) = investment {
136        doc = doc.with_investment(inv);
137    }
138
139    Ok(doc)
140}
141
142// ---------------------------------------------------------------------------
143// Signon message set
144// ---------------------------------------------------------------------------
145
146fn parse_signon_message_set(reader: &mut OfxReader<'_>) -> Result<SignonResponse, OfxError> {
147    loop {
148        match reader.next_event()? {
149            Event::Start(e) => {
150                let name = tag_name(&e);
151                if name == "SONRS" {
152                    let sonrs = parse_sonrs(reader)?;
153                    reader.skip_to_end("SIGNONMSGSRSV1")?;
154                    return Ok(sonrs);
155                }
156                reader.skip_element(&name)?;
157            }
158            Event::End(e) if end_tag_name(&e) == "SIGNONMSGSRSV1" => {
159                return Err(XmlError::MissingElement {
160                    parent: "SIGNONMSGSRSV1".to_owned(),
161                    element: "SONRS".to_owned(),
162                }
163                .into());
164            }
165            Event::Eof => {
166                return Err(XmlError::MalformedXml {
167                    message: "unexpected EOF in SIGNONMSGSRSV1".to_owned(),
168                }
169                .into());
170            }
171            _ => {}
172        }
173    }
174}
175
176fn parse_sonrs(reader: &mut OfxReader<'_>) -> Result<SignonResponse, OfxError> {
177    let mut status: Option<Status> = None;
178    let mut dtserver: Option<crate::types::OfxDateTime> = None;
179    let mut language: Option<String> = None;
180    let mut fi_org: Option<String> = None;
181    let mut fi_id: Option<String> = None;
182    let mut session_cookie: Option<String> = None;
183    let mut access_key: Option<String> = None;
184
185    loop {
186        match reader.next_event()? {
187            Event::Start(e) => {
188                let name = tag_name(&e);
189                match name.as_str() {
190                    "STATUS" => status = Some(parse_status(reader)?),
191                    "DTSERVER" => {
192                        let text = read_text(reader, "DTSERVER")?;
193                        dtserver = Some(parse_text_as(&text, "DTSERVER")?);
194                    }
195                    "LANGUAGE" => language = Some(read_text(reader, "LANGUAGE")?),
196                    "FI" => {
197                        let (org, id) = parse_fi(reader)?;
198                        fi_org = org;
199                        fi_id = id;
200                    }
201                    "SESSCOOKIE" => session_cookie = Some(read_text(reader, "SESSCOOKIE")?),
202                    "ACCESSKEY" => access_key = Some(read_text(reader, "ACCESSKEY")?),
203                    _ => reader.skip_element(&name)?,
204                }
205            }
206            Event::End(e) if end_tag_name(&e) == "SONRS" => break,
207            Event::Eof => {
208                return Err(XmlError::MalformedXml {
209                    message: "unexpected EOF in SONRS".to_owned(),
210                }
211                .into());
212            }
213            _ => {}
214        }
215    }
216
217    let status = status.ok_or_else(|| XmlError::MissingElement {
218        parent: "SONRS".to_owned(),
219        element: "STATUS".to_owned(),
220    })?;
221    let dtserver = dtserver.ok_or_else(|| XmlError::MissingElement {
222        parent: "SONRS".to_owned(),
223        element: "DTSERVER".to_owned(),
224    })?;
225    let language = language.unwrap_or_else(|| "ENG".to_owned());
226
227    let mut sonrs = SignonResponse::new(status, dtserver, language);
228    if let Some(org) = fi_org {
229        sonrs = sonrs.with_fi_org(org);
230    }
231    if let Some(id) = fi_id {
232        sonrs = sonrs.with_fi_id(id);
233    }
234    if let Some(cookie) = session_cookie {
235        sonrs = sonrs.with_session_cookie(cookie);
236    }
237    if let Some(key) = access_key {
238        sonrs = sonrs.with_access_key(key);
239    }
240    Ok(sonrs)
241}
242
243fn parse_fi(reader: &mut OfxReader<'_>) -> Result<(Option<String>, Option<String>), OfxError> {
244    let mut org: Option<String> = None;
245    let mut fid: Option<String> = None;
246
247    loop {
248        match reader.next_event()? {
249            Event::Start(e) => {
250                let name = tag_name(&e);
251                match name.as_str() {
252                    "ORG" => org = Some(read_text(reader, "ORG")?),
253                    "FID" => fid = Some(read_text(reader, "FID")?),
254                    _ => reader.skip_element(&name)?,
255                }
256            }
257            Event::End(e) if end_tag_name(&e) == "FI" => break,
258            Event::Eof => {
259                return Err(XmlError::MalformedXml {
260                    message: "unexpected EOF in FI".to_owned(),
261                }
262                .into());
263            }
264            _ => {}
265        }
266    }
267    Ok((org, fid))
268}
269
270// ---------------------------------------------------------------------------
271// STATUS aggregate (used everywhere)
272// ---------------------------------------------------------------------------
273
274fn parse_status(reader: &mut OfxReader<'_>) -> Result<Status, OfxError> {
275    let mut code: Option<u32> = None;
276    let mut severity: Option<crate::types::Severity> = None;
277    let mut message: Option<String> = None;
278
279    loop {
280        match reader.next_event()? {
281            Event::Start(e) => {
282                let name = tag_name(&e);
283                match name.as_str() {
284                    "CODE" => {
285                        let text = read_text(reader, "CODE")?;
286                        code = Some(parse_text_as(&text, "CODE")?);
287                    }
288                    "SEVERITY" => {
289                        let text = read_text(reader, "SEVERITY")?;
290                        severity = Some(parse_text_as(&text, "SEVERITY")?);
291                    }
292                    "MESSAGE" => message = Some(read_text(reader, "MESSAGE")?),
293                    _ => reader.skip_element(&name)?,
294                }
295            }
296            Event::End(e) if end_tag_name(&e) == "STATUS" => break,
297            Event::Eof => {
298                return Err(XmlError::MalformedXml {
299                    message: "unexpected EOF in STATUS".to_owned(),
300                }
301                .into());
302            }
303            _ => {}
304        }
305    }
306
307    let code = code.ok_or_else(|| XmlError::MissingElement {
308        parent: "STATUS".to_owned(),
309        element: "CODE".to_owned(),
310    })?;
311    let severity = severity.ok_or_else(|| XmlError::MissingElement {
312        parent: "STATUS".to_owned(),
313        element: "SEVERITY".to_owned(),
314    })?;
315
316    Ok(Status::new(code, severity, message))
317}
318
319// ---------------------------------------------------------------------------
320// Banking message set
321// ---------------------------------------------------------------------------
322
323fn parse_banking_message_set(reader: &mut OfxReader<'_>) -> Result<BankingMessageSet, OfxError> {
324    let mut responses = Vec::new();
325
326    loop {
327        match reader.next_event()? {
328            Event::Start(e) => {
329                let name = tag_name(&e);
330                if name == "STMTTRNRS" {
331                    responses.push(parse_stmttrnrs(reader)?);
332                } else {
333                    reader.skip_element(&name)?;
334                }
335            }
336            Event::End(e) if end_tag_name(&e) == "BANKMSGSRSV1" => break,
337            Event::Eof => {
338                return Err(XmlError::MalformedXml {
339                    message: "unexpected EOF in BANKMSGSRSV1".to_owned(),
340                }
341                .into());
342            }
343            _ => {}
344        }
345    }
346
347    Ok(BankingMessageSet::new(responses))
348}
349
350fn parse_stmttrnrs(
351    reader: &mut OfxReader<'_>,
352) -> Result<TransactionWrapper<StatementResponse>, OfxError> {
353    let mut trnuid: Option<String> = None;
354    let mut status: Option<Status> = None;
355    let mut client_cookie: Option<String> = None;
356    let mut stmtrs: Option<StatementResponse> = None;
357
358    loop {
359        match reader.next_event()? {
360            Event::Start(e) => {
361                let name = tag_name(&e);
362                match name.as_str() {
363                    "TRNUID" => trnuid = Some(read_text(reader, "TRNUID")?),
364                    "STATUS" => status = Some(parse_status(reader)?),
365                    "CLTCOOKIE" => client_cookie = Some(read_text(reader, "CLTCOOKIE")?),
366                    "STMTRS" => stmtrs = Some(parse_stmtrs(reader)?),
367                    _ => reader.skip_element(&name)?,
368                }
369            }
370            Event::End(e) if end_tag_name(&e) == "STMTTRNRS" => break,
371            Event::Eof => {
372                return Err(XmlError::MalformedXml {
373                    message: "unexpected EOF in STMTTRNRS".to_owned(),
374                }
375                .into());
376            }
377            _ => {}
378        }
379    }
380
381    let trnuid = trnuid.ok_or_else(|| XmlError::MissingElement {
382        parent: "STMTTRNRS".to_owned(),
383        element: "TRNUID".to_owned(),
384    })?;
385    let status = status.ok_or_else(|| XmlError::MissingElement {
386        parent: "STMTTRNRS".to_owned(),
387        element: "STATUS".to_owned(),
388    })?;
389
390    let mut wrapper = TransactionWrapper::new(trnuid, status, stmtrs);
391    if let Some(cookie) = client_cookie {
392        wrapper = wrapper.with_client_cookie(cookie);
393    }
394    Ok(wrapper)
395}
396
397fn parse_stmtrs(reader: &mut OfxReader<'_>) -> Result<StatementResponse, OfxError> {
398    let mut curdef: Option<crate::types::CurrencyCode> = None;
399    let mut bank_acct: Option<BankAccount> = None;
400    let mut transaction_list: Option<TransactionList> = None;
401    let mut ledger_balance: Option<LedgerBalance> = None;
402    let mut available_balance: Option<AvailableBalance> = None;
403    let mut balance_list: Vec<Balance> = Vec::new();
404    let mut marketing_info: Option<String> = None;
405
406    loop {
407        match reader.next_event()? {
408            Event::Start(e) => {
409                let name = tag_name(&e);
410                match name.as_str() {
411                    "CURDEF" => {
412                        let text = read_text(reader, "CURDEF")?;
413                        curdef = Some(parse_text_as(&text, "CURDEF")?);
414                    }
415                    "BANKACCTFROM" => bank_acct = Some(parse_bank_account(reader, "BANKACCTFROM")?),
416                    "BANKTRANLIST" => transaction_list = Some(parse_bank_transaction_list(reader)?),
417                    "LEDGERBAL" => ledger_balance = Some(parse_ledger_balance(reader)?),
418                    "AVAILBAL" => available_balance = Some(parse_available_balance(reader)?),
419                    "BALLIST" => balance_list = parse_balance_list(reader)?,
420                    "MKTGINFO" => marketing_info = Some(read_text(reader, "MKTGINFO")?),
421                    _ => reader.skip_element(&name)?,
422                }
423            }
424            Event::End(e) if end_tag_name(&e) == "STMTRS" => break,
425            Event::Eof => {
426                return Err(XmlError::MalformedXml {
427                    message: "unexpected EOF in STMTRS".to_owned(),
428                }
429                .into());
430            }
431            _ => {}
432        }
433    }
434
435    let curdef = curdef.ok_or_else(|| XmlError::MissingElement {
436        parent: "STMTRS".to_owned(),
437        element: "CURDEF".to_owned(),
438    })?;
439    let bank_acct = bank_acct.ok_or_else(|| XmlError::MissingElement {
440        parent: "STMTRS".to_owned(),
441        element: "BANKACCTFROM".to_owned(),
442    })?;
443
444    let mut stmt = StatementResponse::new(curdef, bank_acct);
445    if let Some(tl) = transaction_list {
446        stmt = stmt.with_transaction_list(tl);
447    }
448    if let Some(lb) = ledger_balance {
449        stmt = stmt.with_ledger_balance(lb);
450    }
451    if let Some(ab) = available_balance {
452        stmt = stmt.with_available_balance(ab);
453    }
454    if !balance_list.is_empty() {
455        stmt = stmt.with_balance_list(balance_list);
456    }
457    if let Some(info) = marketing_info {
458        stmt = stmt.with_marketing_info(info);
459    }
460
461    Ok(stmt)
462}
463
464// ---------------------------------------------------------------------------
465// Bank transaction list (BANKTRANLIST) -- contains multiple STMTTRN
466// ---------------------------------------------------------------------------
467
468fn parse_bank_transaction_list(
469    reader: &mut OfxReader<'_>,
470) -> Result<TransactionList, OfxError> {
471    let mut dtstart: Option<crate::types::OfxDateTime> = None;
472    let mut dtend: Option<crate::types::OfxDateTime> = None;
473    let mut transactions = Vec::new();
474
475    loop {
476        match reader.next_event()? {
477            Event::Start(e) => {
478                let name = tag_name(&e);
479                match name.as_str() {
480                    "DTSTART" => {
481                        let text = read_text(reader, "DTSTART")?;
482                        dtstart = Some(parse_text_as(&text, "DTSTART")?);
483                    }
484                    "DTEND" => {
485                        let text = read_text(reader, "DTEND")?;
486                        dtend = Some(parse_text_as(&text, "DTEND")?);
487                    }
488                    "STMTTRN" => {
489                        transactions.push(parse_statement_transaction(reader)?);
490                    }
491                    _ => reader.skip_element(&name)?,
492                }
493            }
494            Event::End(e) if end_tag_name(&e) == "BANKTRANLIST" => break,
495            Event::Eof => {
496                return Err(XmlError::MalformedXml {
497                    message: "unexpected EOF in BANKTRANLIST".to_owned(),
498                }
499                .into());
500            }
501            _ => {}
502        }
503    }
504
505    let dtstart = dtstart.ok_or_else(|| XmlError::MissingElement {
506        parent: "BANKTRANLIST".to_owned(),
507        element: "DTSTART".to_owned(),
508    })?;
509    let dtend = dtend.ok_or_else(|| XmlError::MissingElement {
510        parent: "BANKTRANLIST".to_owned(),
511        element: "DTEND".to_owned(),
512    })?;
513
514    Ok(TransactionList::new(dtstart, dtend, transactions))
515}
516
517// ---------------------------------------------------------------------------
518// Statement transaction (STMTTRN) -- full hierarchical parsing
519// ---------------------------------------------------------------------------
520
521fn apply_stmttrn_field(
522    builder: StatementTransactionBuilder,
523    reader: &mut OfxReader<'_>,
524    name: &str,
525) -> Result<StatementTransactionBuilder, OfxError> {
526    Ok(match name {
527        "TRNTYPE" => builder.transaction_type(parse_text_as(&read_text(reader, name)?, name)?),
528        "DTPOSTED" => builder.date_posted(parse_text_as(&read_text(reader, name)?, name)?),
529        "DTUSER" => builder.date_user(parse_text_as(&read_text(reader, name)?, name)?),
530        "DTAVAIL" => builder.date_available(parse_text_as(&read_text(reader, name)?, name)?),
531        "TRNAMT" => builder.amount(parse_text_as(&read_text(reader, name)?, name)?),
532        "FITID" => builder.fit_id(parse_text_as(&read_text(reader, name)?, name)?),
533        "CORRECTFITID" => builder.correction_id(parse_text_as(&read_text(reader, name)?, name)?),
534        "CORRECTACTION" => builder.correction_action(parse_text_as(&read_text(reader, name)?, name)?),
535        "SRVRTID" => builder.server_transaction_id(parse_text_as(&read_text(reader, name)?, name)?),
536        "CHECKNUM" => builder.check_number(parse_text_as(&read_text(reader, name)?, name)?),
537        "REFNUM" => builder.reference_number(read_text(reader, name)?),
538        "SIC" => builder.sic(parse_text_as(&read_text(reader, name)?, name)?),
539        "PAYEEID" => builder.payee_id(read_text(reader, name)?),
540        "NAME" => builder.name(read_text(reader, name)?),
541        "PAYEE" => builder.payee(parse_payee(reader)?),
542        "BANKACCTTO" => builder.bank_account_to(parse_bank_account(reader, name)?),
543        "CCACCTTO" => builder.cc_account_to(parse_cc_account(reader, name)?),
544        "MEMO" => builder.memo(read_text(reader, name)?),
545        "CURRENCY" | "ORIGCURRENCY" => builder.currency(parse_currency(reader, name)?),
546        "INV401KSOURCE" => builder.inv401k_source(parse_text_as(&read_text(reader, name)?, name)?),
547        _ => {
548            reader.skip_element(name)?;
549            builder
550        }
551    })
552}
553
554fn parse_statement_transaction(
555    reader: &mut OfxReader<'_>,
556) -> Result<crate::aggregates::StatementTransaction, OfxError> {
557    let mut builder = StatementTransactionBuilder::new();
558
559    loop {
560        match reader.next_event()? {
561            Event::Start(e) => {
562                let name = tag_name(&e);
563                builder = apply_stmttrn_field(builder, reader, &name)?;
564            }
565            Event::End(e) if end_tag_name(&e) == "STMTTRN" => break,
566            Event::Eof => {
567                return Err(XmlError::MalformedXml {
568                    message: "unexpected EOF in STMTTRN".to_owned(),
569                }
570                .into());
571            }
572            _ => {}
573        }
574    }
575
576    builder.build().map_err(|msg| {
577        OfxError::Xml(XmlError::MalformedXml {
578            message: format!("failed to build STMTTRN: {msg}"),
579        })
580    })
581}
582
583// ---------------------------------------------------------------------------
584// Balance aggregates
585// ---------------------------------------------------------------------------
586
587fn parse_ledger_balance(reader: &mut OfxReader<'_>) -> Result<LedgerBalance, OfxError> {
588    let mut balamt: Option<crate::types::OfxAmount> = None;
589    let mut dtasof: Option<crate::types::OfxDateTime> = None;
590
591    loop {
592        match reader.next_event()? {
593            Event::Start(e) => {
594                let name = tag_name(&e);
595                match name.as_str() {
596                    "BALAMT" => {
597                        let text = read_text(reader, "BALAMT")?;
598                        balamt = Some(parse_text_as(&text, "BALAMT")?);
599                    }
600                    "DTASOF" => {
601                        let text = read_text(reader, "DTASOF")?;
602                        dtasof = Some(parse_text_as(&text, "DTASOF")?);
603                    }
604                    _ => reader.skip_element(&name)?,
605                }
606            }
607            Event::End(e) if end_tag_name(&e) == "LEDGERBAL" => break,
608            Event::Eof => {
609                return Err(XmlError::MalformedXml {
610                    message: "unexpected EOF in LEDGERBAL".to_owned(),
611                }
612                .into());
613            }
614            _ => {}
615        }
616    }
617
618    let balamt = balamt.ok_or_else(|| XmlError::MissingElement {
619        parent: "LEDGERBAL".to_owned(),
620        element: "BALAMT".to_owned(),
621    })?;
622    let dtasof = dtasof.ok_or_else(|| XmlError::MissingElement {
623        parent: "LEDGERBAL".to_owned(),
624        element: "DTASOF".to_owned(),
625    })?;
626
627    Ok(LedgerBalance::new(balamt, dtasof))
628}
629
630fn parse_available_balance(reader: &mut OfxReader<'_>) -> Result<AvailableBalance, OfxError> {
631    let mut balamt: Option<crate::types::OfxAmount> = None;
632    let mut dtasof: Option<crate::types::OfxDateTime> = None;
633
634    loop {
635        match reader.next_event()? {
636            Event::Start(e) => {
637                let name = tag_name(&e);
638                match name.as_str() {
639                    "BALAMT" => {
640                        let text = read_text(reader, "BALAMT")?;
641                        balamt = Some(parse_text_as(&text, "BALAMT")?);
642                    }
643                    "DTASOF" => {
644                        let text = read_text(reader, "DTASOF")?;
645                        dtasof = Some(parse_text_as(&text, "DTASOF")?);
646                    }
647                    _ => reader.skip_element(&name)?,
648                }
649            }
650            Event::End(e) if end_tag_name(&e) == "AVAILBAL" => break,
651            Event::Eof => {
652                return Err(XmlError::MalformedXml {
653                    message: "unexpected EOF in AVAILBAL".to_owned(),
654                }
655                .into());
656            }
657            _ => {}
658        }
659    }
660
661    let balamt = balamt.ok_or_else(|| XmlError::MissingElement {
662        parent: "AVAILBAL".to_owned(),
663        element: "BALAMT".to_owned(),
664    })?;
665    let dtasof = dtasof.ok_or_else(|| XmlError::MissingElement {
666        parent: "AVAILBAL".to_owned(),
667        element: "DTASOF".to_owned(),
668    })?;
669
670    Ok(AvailableBalance::new(balamt, dtasof))
671}
672
673fn parse_balance_list(reader: &mut OfxReader<'_>) -> Result<Vec<Balance>, OfxError> {
674    let mut balances = Vec::new();
675
676    loop {
677        match reader.next_event()? {
678            Event::Start(e) => {
679                let name = tag_name(&e);
680                if name == "BAL" {
681                    balances.push(parse_bal(reader)?);
682                } else {
683                    reader.skip_element(&name)?;
684                }
685            }
686            Event::End(e) if end_tag_name(&e) == "BALLIST" => break,
687            Event::Eof => {
688                return Err(XmlError::MalformedXml {
689                    message: "unexpected EOF in BALLIST".to_owned(),
690                }
691                .into());
692            }
693            _ => {}
694        }
695    }
696
697    Ok(balances)
698}
699
700fn parse_bal(reader: &mut OfxReader<'_>) -> Result<Balance, OfxError> {
701    let mut name: Option<String> = None;
702    let mut desc: Option<String> = None;
703    let mut bal_type: Option<crate::types::BalanceType> = None;
704    let mut value: Option<crate::types::OfxAmount> = None;
705    let mut dtasof: Option<crate::types::OfxDateTime> = None;
706    let mut currency: Option<crate::types::CurrencyCode> = None;
707
708    loop {
709        match reader.next_event()? {
710            Event::Start(e) => {
711                let tag = tag_name(&e);
712                match tag.as_str() {
713                    "NAME" => name = Some(read_text(reader, "NAME")?),
714                    "DESC" => desc = Some(read_text(reader, "DESC")?),
715                    "BALTYPE" => {
716                        let text = read_text(reader, "BALTYPE")?;
717                        bal_type = Some(parse_text_as(&text, "BALTYPE")?);
718                    }
719                    "VALUE" => {
720                        let text = read_text(reader, "VALUE")?;
721                        value = Some(parse_text_as(&text, "VALUE")?);
722                    }
723                    "DTASOF" => {
724                        let text = read_text(reader, "DTASOF")?;
725                        dtasof = Some(parse_text_as(&text, "DTASOF")?);
726                    }
727                    "CURRENCY" => {
728                        let text = read_text(reader, "CURRENCY")?;
729                        currency = Some(parse_text_as(&text, "CURRENCY")?);
730                    }
731                    _ => reader.skip_element(&tag)?,
732                }
733            }
734            Event::End(e) if end_tag_name(&e) == "BAL" => break,
735            Event::Eof => {
736                return Err(XmlError::MalformedXml {
737                    message: "unexpected EOF in BAL".to_owned(),
738                }
739                .into());
740            }
741            _ => {}
742        }
743    }
744
745    let name = name.ok_or_else(|| XmlError::MissingElement {
746        parent: "BAL".to_owned(),
747        element: "NAME".to_owned(),
748    })?;
749    let desc = desc.ok_or_else(|| XmlError::MissingElement {
750        parent: "BAL".to_owned(),
751        element: "DESC".to_owned(),
752    })?;
753    let bal_type = bal_type.ok_or_else(|| XmlError::MissingElement {
754        parent: "BAL".to_owned(),
755        element: "BALTYPE".to_owned(),
756    })?;
757    let value = value.ok_or_else(|| XmlError::MissingElement {
758        parent: "BAL".to_owned(),
759        element: "VALUE".to_owned(),
760    })?;
761
762    Ok(Balance::new(name, desc, bal_type, value, dtasof, currency))
763}
764
765// ---------------------------------------------------------------------------
766// Account aggregates
767// ---------------------------------------------------------------------------
768
769fn parse_bank_account(
770    reader: &mut OfxReader<'_>,
771    closing_tag: &str,
772) -> Result<BankAccount, OfxError> {
773    let mut bank_id: Option<crate::types::BankId> = None;
774    let mut branch_id: Option<crate::types::BranchId> = None;
775    let mut acct_id: Option<crate::types::AccountId> = None;
776    let mut acct_type: Option<crate::types::AccountType> = None;
777    let mut acct_key: Option<String> = None;
778
779    loop {
780        match reader.next_event()? {
781            Event::Start(e) => {
782                let name = tag_name(&e);
783                match name.as_str() {
784                    "BANKID" => {
785                        let text = read_text(reader, "BANKID")?;
786                        bank_id = Some(parse_text_as(&text, "BANKID")?);
787                    }
788                    "BRANCHID" => {
789                        let text = read_text(reader, "BRANCHID")?;
790                        branch_id = Some(parse_text_as(&text, "BRANCHID")?);
791                    }
792                    "ACCTID" => {
793                        let text = read_text(reader, "ACCTID")?;
794                        acct_id = Some(parse_text_as(&text, "ACCTID")?);
795                    }
796                    "ACCTTYPE" => {
797                        let text = read_text(reader, "ACCTTYPE")?;
798                        acct_type = Some(parse_text_as(&text, "ACCTTYPE")?);
799                    }
800                    "ACCTKEY" => acct_key = Some(read_text(reader, "ACCTKEY")?),
801                    _ => reader.skip_element(&name)?,
802                }
803            }
804            Event::End(e) if end_tag_name(&e) == closing_tag => break,
805            Event::Eof => {
806                return Err(XmlError::MalformedXml {
807                    message: format!("unexpected EOF in {closing_tag}"),
808                }
809                .into());
810            }
811            _ => {}
812        }
813    }
814
815    let bank_id = bank_id.ok_or_else(|| XmlError::MissingElement {
816        parent: closing_tag.to_owned(),
817        element: "BANKID".to_owned(),
818    })?;
819    let acct_id = acct_id.ok_or_else(|| XmlError::MissingElement {
820        parent: closing_tag.to_owned(),
821        element: "ACCTID".to_owned(),
822    })?;
823    let acct_type = acct_type.ok_or_else(|| XmlError::MissingElement {
824        parent: closing_tag.to_owned(),
825        element: "ACCTTYPE".to_owned(),
826    })?;
827
828    Ok(BankAccount::new(
829        bank_id, branch_id, acct_id, acct_type, acct_key,
830    ))
831}
832
833fn parse_cc_account(
834    reader: &mut OfxReader<'_>,
835    closing_tag: &str,
836) -> Result<CreditCardAccount, OfxError> {
837    let mut acct_id: Option<crate::types::AccountId> = None;
838    let mut acct_key: Option<String> = None;
839
840    loop {
841        match reader.next_event()? {
842            Event::Start(e) => {
843                let name = tag_name(&e);
844                match name.as_str() {
845                    "ACCTID" => {
846                        let text = read_text(reader, "ACCTID")?;
847                        acct_id = Some(parse_text_as(&text, "ACCTID")?);
848                    }
849                    "ACCTKEY" => acct_key = Some(read_text(reader, "ACCTKEY")?),
850                    _ => reader.skip_element(&name)?,
851                }
852            }
853            Event::End(e) if end_tag_name(&e) == closing_tag => break,
854            Event::Eof => {
855                return Err(XmlError::MalformedXml {
856                    message: format!("unexpected EOF in {closing_tag}"),
857                }
858                .into());
859            }
860            _ => {}
861        }
862    }
863
864    let acct_id = acct_id.ok_or_else(|| XmlError::MissingElement {
865        parent: closing_tag.to_owned(),
866        element: "ACCTID".to_owned(),
867    })?;
868
869    Ok(CreditCardAccount::new(acct_id, acct_key))
870}
871
872// ---------------------------------------------------------------------------
873// Payee aggregate
874// ---------------------------------------------------------------------------
875
876fn parse_payee(reader: &mut OfxReader<'_>) -> Result<Payee, OfxError> {
877    let mut name: Option<String> = None;
878    let mut addr1: Option<String> = None;
879    let mut addr2: Option<String> = None;
880    let mut addr3: Option<String> = None;
881    let mut city: Option<String> = None;
882    let mut state: Option<String> = None;
883    let mut postalcode: Option<String> = None;
884    let mut country: Option<String> = None;
885    let mut phone: Option<String> = None;
886
887    loop {
888        match reader.next_event()? {
889            Event::Start(e) => {
890                let tag = tag_name(&e);
891                match tag.as_str() {
892                    "NAME" => name = Some(read_text(reader, "NAME")?),
893                    "ADDR1" => addr1 = Some(read_text(reader, "ADDR1")?),
894                    "ADDR2" => addr2 = Some(read_text(reader, "ADDR2")?),
895                    "ADDR3" => addr3 = Some(read_text(reader, "ADDR3")?),
896                    "CITY" => city = Some(read_text(reader, "CITY")?),
897                    "STATE" => state = Some(read_text(reader, "STATE")?),
898                    "POSTALCODE" => postalcode = Some(read_text(reader, "POSTALCODE")?),
899                    "COUNTRY" => country = Some(read_text(reader, "COUNTRY")?),
900                    "PHONE" => phone = Some(read_text(reader, "PHONE")?),
901                    _ => reader.skip_element(&tag)?,
902                }
903            }
904            Event::End(e) if end_tag_name(&e) == "PAYEE" => break,
905            Event::Eof => {
906                return Err(XmlError::MalformedXml {
907                    message: "unexpected EOF in PAYEE".to_owned(),
908                }
909                .into());
910            }
911            _ => {}
912        }
913    }
914
915    let name = name.ok_or_else(|| XmlError::MissingElement {
916        parent: "PAYEE".to_owned(),
917        element: "NAME".to_owned(),
918    })?;
919    let addr1 = addr1.ok_or_else(|| XmlError::MissingElement {
920        parent: "PAYEE".to_owned(),
921        element: "ADDR1".to_owned(),
922    })?;
923    let city = city.ok_or_else(|| XmlError::MissingElement {
924        parent: "PAYEE".to_owned(),
925        element: "CITY".to_owned(),
926    })?;
927    let state = state.ok_or_else(|| XmlError::MissingElement {
928        parent: "PAYEE".to_owned(),
929        element: "STATE".to_owned(),
930    })?;
931    let postalcode = postalcode.ok_or_else(|| XmlError::MissingElement {
932        parent: "PAYEE".to_owned(),
933        element: "POSTALCODE".to_owned(),
934    })?;
935    let phone = phone.ok_or_else(|| XmlError::MissingElement {
936        parent: "PAYEE".to_owned(),
937        element: "PHONE".to_owned(),
938    })?;
939
940    let mut payee = Payee::new(name, addr1, city, state, postalcode, phone);
941    if let Some(a2) = addr2 {
942        payee = payee.with_address2(a2);
943    }
944    if let Some(a3) = addr3 {
945        payee = payee.with_address3(a3);
946    }
947    if let Some(c) = country {
948        payee = payee.with_country(c);
949    }
950    Ok(payee)
951}
952
953// ---------------------------------------------------------------------------
954// Currency aggregate
955// ---------------------------------------------------------------------------
956
957fn parse_currency(reader: &mut OfxReader<'_>, closing_tag: &str) -> Result<CurrencyInfo, OfxError> {
958    let mut currate: Option<crate::types::OfxAmount> = None;
959    let mut cursym: Option<crate::types::CurrencyCode> = None;
960
961    loop {
962        match reader.next_event()? {
963            Event::Start(e) => {
964                let name = tag_name(&e);
965                match name.as_str() {
966                    "CURRATE" => {
967                        let text = read_text(reader, "CURRATE")?;
968                        currate = Some(parse_text_as(&text, "CURRATE")?);
969                    }
970                    "CURSYM" => {
971                        let text = read_text(reader, "CURSYM")?;
972                        cursym = Some(parse_text_as(&text, "CURSYM")?);
973                    }
974                    _ => reader.skip_element(&name)?,
975                }
976            }
977            Event::End(e) if end_tag_name(&e) == closing_tag => break,
978            Event::Eof => {
979                return Err(XmlError::MalformedXml {
980                    message: format!("unexpected EOF in {closing_tag}"),
981                }
982                .into());
983            }
984            _ => {}
985        }
986    }
987
988    let currate = currate.ok_or_else(|| XmlError::MissingElement {
989        parent: closing_tag.to_owned(),
990        element: "CURRATE".to_owned(),
991    })?;
992    let cursym = cursym.ok_or_else(|| XmlError::MissingElement {
993        parent: closing_tag.to_owned(),
994        element: "CURSYM".to_owned(),
995    })?;
996
997    Ok(match closing_tag {
998        "ORIGCURRENCY" => CurrencyInfo::OrigCurrency {
999            code: cursym,
1000            rate: currate,
1001        },
1002        _ => CurrencyInfo::Currency {
1003            code: cursym,
1004            rate: currate,
1005        },
1006    })
1007}
1008
1009// ---------------------------------------------------------------------------
1010// Credit card message set
1011// ---------------------------------------------------------------------------
1012
1013fn parse_cc_message_set(reader: &mut OfxReader<'_>) -> Result<CreditCardMessageSet, OfxError> {
1014    let mut responses = Vec::new();
1015
1016    loop {
1017        match reader.next_event()? {
1018            Event::Start(e) => {
1019                let name = tag_name(&e);
1020                if name == "CCSTMTTRNRS" {
1021                    responses.push(parse_ccstmttrnrs(reader)?);
1022                } else {
1023                    reader.skip_element(&name)?;
1024                }
1025            }
1026            Event::End(e) if end_tag_name(&e) == "CREDITCARDMSGSRSV1" => break,
1027            Event::Eof => {
1028                return Err(XmlError::MalformedXml {
1029                    message: "unexpected EOF in CREDITCARDMSGSRSV1".to_owned(),
1030                }
1031                .into());
1032            }
1033            _ => {}
1034        }
1035    }
1036
1037    Ok(CreditCardMessageSet::new(responses))
1038}
1039
1040fn parse_ccstmttrnrs(
1041    reader: &mut OfxReader<'_>,
1042) -> Result<TransactionWrapper<CcStatementResponse>, OfxError> {
1043    let mut trnuid: Option<String> = None;
1044    let mut status: Option<Status> = None;
1045    let mut client_cookie: Option<String> = None;
1046    let mut ccstmtrs: Option<CcStatementResponse> = None;
1047
1048    loop {
1049        match reader.next_event()? {
1050            Event::Start(e) => {
1051                let name = tag_name(&e);
1052                match name.as_str() {
1053                    "TRNUID" => trnuid = Some(read_text(reader, "TRNUID")?),
1054                    "STATUS" => status = Some(parse_status(reader)?),
1055                    "CLTCOOKIE" => client_cookie = Some(read_text(reader, "CLTCOOKIE")?),
1056                    "CCSTMTRS" => ccstmtrs = Some(parse_ccstmtrs(reader)?),
1057                    _ => reader.skip_element(&name)?,
1058                }
1059            }
1060            Event::End(e) if end_tag_name(&e) == "CCSTMTTRNRS" => break,
1061            Event::Eof => {
1062                return Err(XmlError::MalformedXml {
1063                    message: "unexpected EOF in CCSTMTTRNRS".to_owned(),
1064                }
1065                .into());
1066            }
1067            _ => {}
1068        }
1069    }
1070
1071    let trnuid = trnuid.ok_or_else(|| XmlError::MissingElement {
1072        parent: "CCSTMTTRNRS".to_owned(),
1073        element: "TRNUID".to_owned(),
1074    })?;
1075    let status = status.ok_or_else(|| XmlError::MissingElement {
1076        parent: "CCSTMTTRNRS".to_owned(),
1077        element: "STATUS".to_owned(),
1078    })?;
1079
1080    let mut wrapper = TransactionWrapper::new(trnuid, status, ccstmtrs);
1081    if let Some(cookie) = client_cookie {
1082        wrapper = wrapper.with_client_cookie(cookie);
1083    }
1084    Ok(wrapper)
1085}
1086
1087fn parse_ccstmtrs(reader: &mut OfxReader<'_>) -> Result<CcStatementResponse, OfxError> {
1088    let mut curdef: Option<crate::types::CurrencyCode> = None;
1089    let mut cc_acct: Option<CreditCardAccount> = None;
1090    let mut transaction_list: Option<TransactionList> = None;
1091    let mut ledger_balance: Option<LedgerBalance> = None;
1092    let mut available_balance: Option<AvailableBalance> = None;
1093    let mut balance_list: Vec<Balance> = Vec::new();
1094    let mut marketing_info: Option<String> = None;
1095
1096    loop {
1097        match reader.next_event()? {
1098            Event::Start(e) => {
1099                let name = tag_name(&e);
1100                match name.as_str() {
1101                    "CURDEF" => {
1102                        let text = read_text(reader, "CURDEF")?;
1103                        curdef = Some(parse_text_as(&text, "CURDEF")?);
1104                    }
1105                    "CCACCTFROM" => cc_acct = Some(parse_cc_account(reader, "CCACCTFROM")?),
1106                    "BANKTRANLIST" => transaction_list = Some(parse_bank_transaction_list(reader)?),
1107                    "LEDGERBAL" => ledger_balance = Some(parse_ledger_balance(reader)?),
1108                    "AVAILBAL" => available_balance = Some(parse_available_balance(reader)?),
1109                    "BALLIST" => balance_list = parse_balance_list(reader)?,
1110                    "MKTGINFO" => marketing_info = Some(read_text(reader, "MKTGINFO")?),
1111                    _ => reader.skip_element(&name)?,
1112                }
1113            }
1114            Event::End(e) if end_tag_name(&e) == "CCSTMTRS" => break,
1115            Event::Eof => {
1116                return Err(XmlError::MalformedXml {
1117                    message: "unexpected EOF in CCSTMTRS".to_owned(),
1118                }
1119                .into());
1120            }
1121            _ => {}
1122        }
1123    }
1124
1125    let curdef = curdef.ok_or_else(|| XmlError::MissingElement {
1126        parent: "CCSTMTRS".to_owned(),
1127        element: "CURDEF".to_owned(),
1128    })?;
1129    let cc_acct = cc_acct.ok_or_else(|| XmlError::MissingElement {
1130        parent: "CCSTMTRS".to_owned(),
1131        element: "CCACCTFROM".to_owned(),
1132    })?;
1133
1134    let mut stmt = CcStatementResponse::new(curdef, cc_acct);
1135    if let Some(tl) = transaction_list {
1136        stmt = stmt.with_transaction_list(tl);
1137    }
1138    if let Some(lb) = ledger_balance {
1139        stmt = stmt.with_ledger_balance(lb);
1140    }
1141    if let Some(ab) = available_balance {
1142        stmt = stmt.with_available_balance(ab);
1143    }
1144    if !balance_list.is_empty() {
1145        stmt = stmt.with_balance_list(balance_list);
1146    }
1147    if let Some(info) = marketing_info {
1148        stmt = stmt.with_marketing_info(info);
1149    }
1150
1151    Ok(stmt)
1152}
1153
1154// ---------------------------------------------------------------------------
1155// Investment message set
1156// ---------------------------------------------------------------------------
1157
1158fn parse_investment_message_set(
1159    reader: &mut OfxReader<'_>,
1160) -> Result<InvestmentMessageSet, OfxError> {
1161    let mut responses = Vec::new();
1162
1163    loop {
1164        match reader.next_event()? {
1165            Event::Start(e) => {
1166                let name = tag_name(&e);
1167                if name == "INVSTMTTRNRS" {
1168                    responses.push(parse_invstmttrnrs(reader)?);
1169                } else {
1170                    reader.skip_element(&name)?;
1171                }
1172            }
1173            Event::End(e) if end_tag_name(&e) == "INVSTMTMSGSRSV1" => break,
1174            Event::Eof => {
1175                return Err(XmlError::MalformedXml {
1176                    message: "unexpected EOF in INVSTMTMSGSRSV1".to_owned(),
1177                }
1178                .into());
1179            }
1180            _ => {}
1181        }
1182    }
1183
1184    Ok(InvestmentMessageSet::new(responses))
1185}
1186
1187fn parse_invstmttrnrs(
1188    reader: &mut OfxReader<'_>,
1189) -> Result<TransactionWrapper<InvStatementResponse>, OfxError> {
1190    let mut trnuid: Option<String> = None;
1191    let mut status: Option<Status> = None;
1192    let mut client_cookie: Option<String> = None;
1193    let mut invstmtrs: Option<InvStatementResponse> = None;
1194
1195    loop {
1196        match reader.next_event()? {
1197            Event::Start(e) => {
1198                let name = tag_name(&e);
1199                match name.as_str() {
1200                    "TRNUID" => trnuid = Some(read_text(reader, "TRNUID")?),
1201                    "STATUS" => status = Some(parse_status(reader)?),
1202                    "CLTCOOKIE" => client_cookie = Some(read_text(reader, "CLTCOOKIE")?),
1203                    "INVSTMTRS" => invstmtrs = Some(parse_invstmtrs(reader)?),
1204                    _ => reader.skip_element(&name)?,
1205                }
1206            }
1207            Event::End(e) if end_tag_name(&e) == "INVSTMTTRNRS" => break,
1208            Event::Eof => {
1209                return Err(XmlError::MalformedXml {
1210                    message: "unexpected EOF in INVSTMTTRNRS".to_owned(),
1211                }
1212                .into());
1213            }
1214            _ => {}
1215        }
1216    }
1217
1218    let trnuid = trnuid.ok_or_else(|| XmlError::MissingElement {
1219        parent: "INVSTMTTRNRS".to_owned(),
1220        element: "TRNUID".to_owned(),
1221    })?;
1222    let status = status.ok_or_else(|| XmlError::MissingElement {
1223        parent: "INVSTMTTRNRS".to_owned(),
1224        element: "STATUS".to_owned(),
1225    })?;
1226
1227    let mut wrapper = TransactionWrapper::new(trnuid, status, invstmtrs);
1228    if let Some(cookie) = client_cookie {
1229        wrapper = wrapper.with_client_cookie(cookie);
1230    }
1231    Ok(wrapper)
1232}
1233
1234fn parse_invstmtrs(reader: &mut OfxReader<'_>) -> Result<InvStatementResponse, OfxError> {
1235    let mut curdef: Option<crate::types::CurrencyCode> = None;
1236    let mut inv_acct: Option<InvestmentAccount> = None;
1237    let mut transaction_list: Option<TransactionList> = None;
1238
1239    loop {
1240        match reader.next_event()? {
1241            Event::Start(e) => {
1242                let name = tag_name(&e);
1243                match name.as_str() {
1244                    "CURDEF" => {
1245                        let text = read_text(reader, "CURDEF")?;
1246                        curdef = Some(parse_text_as(&text, "CURDEF")?);
1247                    }
1248                    "INVACCTFROM" => {
1249                        inv_acct = Some(parse_investment_account(reader)?);
1250                    }
1251                    "INVTRANLIST" => {
1252                        transaction_list = Some(parse_inv_transaction_list(reader)?);
1253                    }
1254                    _ => reader.skip_element(&name)?,
1255                }
1256            }
1257            Event::End(e) if end_tag_name(&e) == "INVSTMTRS" => break,
1258            Event::Eof => {
1259                return Err(XmlError::MalformedXml {
1260                    message: "unexpected EOF in INVSTMTRS".to_owned(),
1261                }
1262                .into());
1263            }
1264            _ => {}
1265        }
1266    }
1267
1268    let curdef = curdef.ok_or_else(|| XmlError::MissingElement {
1269        parent: "INVSTMTRS".to_owned(),
1270        element: "CURDEF".to_owned(),
1271    })?;
1272    let inv_acct = inv_acct.ok_or_else(|| XmlError::MissingElement {
1273        parent: "INVSTMTRS".to_owned(),
1274        element: "INVACCTFROM".to_owned(),
1275    })?;
1276
1277    let mut stmt = InvStatementResponse::new(curdef, inv_acct);
1278    if let Some(tl) = transaction_list {
1279        stmt = stmt.with_transaction_list(tl);
1280    }
1281
1282    Ok(stmt)
1283}
1284
1285fn parse_investment_account(reader: &mut OfxReader<'_>) -> Result<InvestmentAccount, OfxError> {
1286    let mut broker_id: Option<String> = None;
1287    let mut acct_id: Option<crate::types::AccountId> = None;
1288
1289    loop {
1290        match reader.next_event()? {
1291            Event::Start(e) => {
1292                let name = tag_name(&e);
1293                match name.as_str() {
1294                    "BROKERID" => broker_id = Some(read_text(reader, "BROKERID")?),
1295                    "ACCTID" => {
1296                        let text = read_text(reader, "ACCTID")?;
1297                        acct_id = Some(parse_text_as(&text, "ACCTID")?);
1298                    }
1299                    _ => reader.skip_element(&name)?,
1300                }
1301            }
1302            Event::End(e) if end_tag_name(&e) == "INVACCTFROM" => break,
1303            Event::Eof => {
1304                return Err(XmlError::MalformedXml {
1305                    message: "unexpected EOF in INVACCTFROM".to_owned(),
1306                }
1307                .into());
1308            }
1309            _ => {}
1310        }
1311    }
1312
1313    let broker_id = broker_id.ok_or_else(|| XmlError::MissingElement {
1314        parent: "INVACCTFROM".to_owned(),
1315        element: "BROKERID".to_owned(),
1316    })?;
1317    let acct_id = acct_id.ok_or_else(|| XmlError::MissingElement {
1318        parent: "INVACCTFROM".to_owned(),
1319        element: "ACCTID".to_owned(),
1320    })?;
1321
1322    Ok(InvestmentAccount::new(broker_id, acct_id))
1323}
1324
1325/// Parses INVTRANLIST, extracting only INVBANKTRAN/STMTTRN banking transactions.
1326/// Investment-specific transactions (BUYSTOCK, SELLSTOCK, etc.) are skipped.
1327fn parse_inv_transaction_list(reader: &mut OfxReader<'_>) -> Result<TransactionList, OfxError> {
1328    let mut dtstart: Option<crate::types::OfxDateTime> = None;
1329    let mut dtend: Option<crate::types::OfxDateTime> = None;
1330    let mut transactions = Vec::new();
1331
1332    loop {
1333        match reader.next_event()? {
1334            Event::Start(e) => {
1335                let name = tag_name(&e);
1336                match name.as_str() {
1337                    "DTSTART" => {
1338                        let text = read_text(reader, "DTSTART")?;
1339                        dtstart = Some(parse_text_as(&text, "DTSTART")?);
1340                    }
1341                    "DTEND" => {
1342                        let text = read_text(reader, "DTEND")?;
1343                        dtend = Some(parse_text_as(&text, "DTEND")?);
1344                    }
1345                    "INVBANKTRAN" => {
1346                        if let Some(txn) = parse_invbanktran(reader)? {
1347                            transactions.push(txn);
1348                        }
1349                    }
1350                    _ => reader.skip_element(&name)?,
1351                }
1352            }
1353            Event::End(e) if end_tag_name(&e) == "INVTRANLIST" => break,
1354            Event::Eof => {
1355                return Err(XmlError::MalformedXml {
1356                    message: "unexpected EOF in INVTRANLIST".to_owned(),
1357                }
1358                .into());
1359            }
1360            _ => {}
1361        }
1362    }
1363
1364    let dtstart = dtstart.ok_or_else(|| XmlError::MissingElement {
1365        parent: "INVTRANLIST".to_owned(),
1366        element: "DTSTART".to_owned(),
1367    })?;
1368    let dtend = dtend.ok_or_else(|| XmlError::MissingElement {
1369        parent: "INVTRANLIST".to_owned(),
1370        element: "DTEND".to_owned(),
1371    })?;
1372
1373    Ok(TransactionList::new(dtstart, dtend, transactions))
1374}
1375
1376/// Parses an INVBANKTRAN element, extracting the STMTTRN inside it.
1377/// Returns `None` if no STMTTRN is found (defensive, but shouldn't happen
1378/// in well-formed OFX).
1379fn parse_invbanktran(
1380    reader: &mut OfxReader<'_>,
1381) -> Result<Option<crate::aggregates::StatementTransaction>, OfxError> {
1382    let mut transaction = None;
1383
1384    loop {
1385        match reader.next_event()? {
1386            Event::Start(e) => {
1387                let name = tag_name(&e);
1388                if name == "STMTTRN" {
1389                    transaction = Some(parse_statement_transaction(reader)?);
1390                } else {
1391                    reader.skip_element(&name)?;
1392                }
1393            }
1394            Event::End(e) if end_tag_name(&e) == "INVBANKTRAN" => break,
1395            Event::Eof => {
1396                return Err(XmlError::MalformedXml {
1397                    message: "unexpected EOF in INVBANKTRAN".to_owned(),
1398                }
1399                .into());
1400            }
1401            _ => {}
1402        }
1403    }
1404
1405    Ok(transaction)
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411
1412    const SIMPLE_BANK_OFX: &str = r#"<?OFX OFXHEADER="200" VERSION="220" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>
1413<OFX>
1414<SIGNONMSGSRSV1>
1415<SONRS>
1416<STATUS>
1417<CODE>0</CODE>
1418<SEVERITY>INFO</SEVERITY>
1419</STATUS>
1420<DTSERVER>20230115120000</DTSERVER>
1421<LANGUAGE>ENG</LANGUAGE>
1422<FI>
1423<ORG>MyBank</ORG>
1424<FID>1234</FID>
1425</FI>
1426</SONRS>
1427</SIGNONMSGSRSV1>
1428<BANKMSGSRSV1>
1429<STMTTRNRS>
1430<TRNUID>1001</TRNUID>
1431<STATUS>
1432<CODE>0</CODE>
1433<SEVERITY>INFO</SEVERITY>
1434</STATUS>
1435<STMTRS>
1436<CURDEF>USD</CURDEF>
1437<BANKACCTFROM>
1438<BANKID>123456789</BANKID>
1439<ACCTID>987654321</ACCTID>
1440<ACCTTYPE>CHECKING</ACCTTYPE>
1441</BANKACCTFROM>
1442<BANKTRANLIST>
1443<DTSTART>20230101</DTSTART>
1444<DTEND>20230131</DTEND>
1445<STMTTRN>
1446<TRNTYPE>DEBIT</TRNTYPE>
1447<DTPOSTED>20230115</DTPOSTED>
1448<TRNAMT>-50.00</TRNAMT>
1449<FITID>20230115001</FITID>
1450<NAME>GROCERY STORE</NAME>
1451<MEMO>Weekly groceries</MEMO>
1452</STMTTRN>
1453</BANKTRANLIST>
1454<LEDGERBAL>
1455<BALAMT>1500.00</BALAMT>
1456<DTASOF>20230115120000</DTASOF>
1457</LEDGERBAL>
1458</STMTRS>
1459</STMTTRNRS>
1460</BANKMSGSRSV1>
1461</OFX>"#;
1462
1463    #[test]
1464    fn parse_simple_bank_statement() {
1465        let doc = parse(SIMPLE_BANK_OFX).unwrap();
1466
1467        assert_eq!(doc.header().version(), "220".parse().unwrap());
1468        assert!(doc.signon().status().is_success());
1469        assert_eq!(doc.signon().language(), "ENG");
1470
1471        let banking = doc.banking().expect("banking should be present");
1472        assert_eq!(banking.statement_responses().len(), 1);
1473
1474        let wrapper = &banking.statement_responses()[0];
1475        assert!(wrapper.status().is_success());
1476        assert_eq!(wrapper.transaction_uid(), "1001");
1477
1478        let stmt = wrapper.response().expect("response should be present");
1479        assert_eq!(stmt.currency_default().as_str(), "USD");
1480        assert_eq!(stmt.bank_account().bank_id().as_str(), "123456789");
1481        assert_eq!(stmt.bank_account().account_id().as_str(), "987654321");
1482
1483        let txn_list = stmt
1484            .transaction_list()
1485            .expect("transaction list should be present");
1486        assert_eq!(txn_list.len(), 1);
1487
1488        let txn = &txn_list.transactions()[0];
1489        assert_eq!(txn.transaction_type(), crate::types::TransactionType::Debit);
1490        assert_eq!(txn.name(), Some("GROCERY STORE"));
1491        assert_eq!(txn.memo(), Some("Weekly groceries"));
1492
1493        let ledger = stmt
1494            .ledger_balance()
1495            .expect("ledger balance should be present");
1496        assert_eq!(
1497            ledger.amount().as_decimal(),
1498            rust_decimal::Decimal::new(150000, 2)
1499        );
1500    }
1501
1502    #[test]
1503    fn parse_multiple_transactions() {
1504        let input = r#"<?OFX OFXHEADER="200" VERSION="220" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>
1505<OFX>
1506<SIGNONMSGSRSV1>
1507<SONRS>
1508<STATUS><CODE>0</CODE><SEVERITY>INFO</SEVERITY></STATUS>
1509<DTSERVER>20230131120000</DTSERVER>
1510<LANGUAGE>ENG</LANGUAGE>
1511</SONRS>
1512</SIGNONMSGSRSV1>
1513<BANKMSGSRSV1>
1514<STMTTRNRS>
1515<TRNUID>2001</TRNUID>
1516<STATUS><CODE>0</CODE><SEVERITY>INFO</SEVERITY></STATUS>
1517<STMTRS>
1518<CURDEF>USD</CURDEF>
1519<BANKACCTFROM>
1520<BANKID>111222333</BANKID>
1521<ACCTID>444555666</ACCTID>
1522<ACCTTYPE>CHECKING</ACCTTYPE>
1523</BANKACCTFROM>
1524<BANKTRANLIST>
1525<DTSTART>20230101</DTSTART>
1526<DTEND>20230131</DTEND>
1527<STMTTRN>
1528<TRNTYPE>DEBIT</TRNTYPE>
1529<DTPOSTED>20230105</DTPOSTED>
1530<TRNAMT>-25.00</TRNAMT>
1531<FITID>TXN001</FITID>
1532<NAME>COFFEE SHOP</NAME>
1533</STMTTRN>
1534<STMTTRN>
1535<TRNTYPE>CREDIT</TRNTYPE>
1536<DTPOSTED>20230110</DTPOSTED>
1537<TRNAMT>3000.00</TRNAMT>
1538<FITID>TXN002</FITID>
1539<NAME>PAYROLL</NAME>
1540<MEMO>Direct deposit</MEMO>
1541</STMTTRN>
1542<STMTTRN>
1543<TRNTYPE>DEBIT</TRNTYPE>
1544<DTPOSTED>20230115</DTPOSTED>
1545<TRNAMT>-1200.00</TRNAMT>
1546<FITID>TXN003</FITID>
1547<NAME>RENT PAYMENT</NAME>
1548<CHECKNUM>1042</CHECKNUM>
1549</STMTTRN>
1550<STMTTRN>
1551<TRNTYPE>DEBIT</TRNTYPE>
1552<DTPOSTED>20230120</DTPOSTED>
1553<TRNAMT>-89.50</TRNAMT>
1554<FITID>TXN004</FITID>
1555<NAME>ELECTRIC COMPANY</NAME>
1556<MEMO>Monthly bill</MEMO>
1557</STMTTRN>
1558<STMTTRN>
1559<TRNTYPE>DEBIT</TRNTYPE>
1560<DTPOSTED>20230125</DTPOSTED>
1561<TRNAMT>-42.99</TRNAMT>
1562<FITID>TXN005</FITID>
1563<NAME>STREAMING SERVICE</NAME>
1564<SIC>7812</SIC>
1565</STMTTRN>
1566</BANKTRANLIST>
1567<LEDGERBAL>
1568<BALAMT>1642.51</BALAMT>
1569<DTASOF>20230131120000</DTASOF>
1570</LEDGERBAL>
1571<AVAILBAL>
1572<BALAMT>1542.51</BALAMT>
1573<DTASOF>20230131120000</DTASOF>
1574</AVAILBAL>
1575</STMTRS>
1576</STMTTRNRS>
1577</BANKMSGSRSV1>
1578</OFX>"#;
1579
1580        let doc = parse(input).unwrap();
1581        let banking = doc.banking().unwrap();
1582        let stmt = banking.statement_responses()[0].response().unwrap();
1583
1584        // Verify all 5 transactions were parsed
1585        let txn_list = stmt.transaction_list().unwrap();
1586        assert_eq!(txn_list.len(), 5);
1587
1588        // Verify each transaction's unique data
1589        assert_eq!(txn_list.transactions()[0].fit_id().as_str(), "TXN001");
1590        assert_eq!(txn_list.transactions()[0].name(), Some("COFFEE SHOP"));
1591        assert_eq!(
1592            txn_list.transactions()[0].amount().as_decimal(),
1593            rust_decimal::Decimal::new(-2500, 2)
1594        );
1595
1596        assert_eq!(txn_list.transactions()[1].fit_id().as_str(), "TXN002");
1597        assert_eq!(
1598            txn_list.transactions()[1].transaction_type(),
1599            crate::types::TransactionType::Credit
1600        );
1601        assert_eq!(txn_list.transactions()[1].memo(), Some("Direct deposit"));
1602
1603        assert_eq!(txn_list.transactions()[2].fit_id().as_str(), "TXN003");
1604        assert_eq!(
1605            txn_list.transactions()[2].check_number().unwrap().as_str(),
1606            "1042"
1607        );
1608
1609        assert_eq!(txn_list.transactions()[3].fit_id().as_str(), "TXN004");
1610        assert_eq!(txn_list.transactions()[3].memo(), Some("Monthly bill"));
1611
1612        assert_eq!(txn_list.transactions()[4].fit_id().as_str(), "TXN005");
1613        assert_eq!(txn_list.transactions()[4].sic(), Some(7812));
1614
1615        // Verify both balances
1616        let ledger = stmt.ledger_balance().unwrap();
1617        assert_eq!(
1618            ledger.amount().as_decimal(),
1619            rust_decimal::Decimal::new(164251, 2)
1620        );
1621
1622        let avail = stmt.available_balance().unwrap();
1623        assert_eq!(
1624            avail.amount().as_decimal(),
1625            rust_decimal::Decimal::new(154251, 2)
1626        );
1627    }
1628
1629    #[test]
1630    fn parse_missing_header_returns_error() {
1631        let result = parse("<OFX></OFX>");
1632        assert!(result.is_err());
1633    }
1634
1635    #[test]
1636    fn parse_missing_signon_returns_error() {
1637        let input = r#"<?OFX OFXHEADER="200" VERSION="220" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?><OFX></OFX>"#;
1638        let result = parse(input);
1639        assert!(result.is_err());
1640    }
1641}