Skip to main content

bank_statement_rs/
builder.rs

1use std::fs;
2
3use crate::{errors::StatementParseError, parsers::prelude::*, types::Transaction};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum ParsedTransaction {
8    Qfx(QfxTransaction),
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum FileFormat {
13    #[serde(rename = "qfx")]
14    Qfx,
15}
16
17impl FileFormat {
18    fn parse_raw(&self, content: &str) -> Result<Vec<ParsedTransaction>, StatementParseError> {
19        match self {
20            FileFormat::Qfx => {
21                let transactions =
22                    QfxParser::parse(content).map_err(StatementParseError::ParseFailed)?;
23                Ok(transactions
24                    .into_iter()
25                    .map(ParsedTransaction::Qfx)
26                    .collect())
27            }
28        }
29    }
30
31    fn parse<T>(&self, content: &str) -> Result<Vec<T>, StatementParseError>
32    where
33        T: TryFrom<ParsedTransaction, Error = StatementParseError>,
34    {
35        self.parse_raw(content)?
36            .into_iter()
37            .map(T::try_from)
38            .collect()
39    }
40
41    fn detect(filename: Option<&str>, content: Option<&str>) -> Result<Self, StatementParseError> {
42        if let Some(content) = content {
43            if QfxParser::is_supported(filename, content) {
44                return Ok(FileFormat::Qfx);
45            }
46        }
47
48        if let Some(filename) = filename {
49            if let Some(ext) = filename.split('.').last() {
50                if matches!(ext, "qfx" | "ofx") {
51                    return Ok(FileFormat::Qfx);
52                }
53            }
54        }
55
56        Err(StatementParseError::UnsupportedFormat)
57    }
58}
59
60#[derive(Default)]
61pub struct ParserBuilder {
62    content: Option<String>,
63    filepath: Option<String>,
64    format: Option<FileFormat>,
65}
66
67impl ParserBuilder {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    pub fn content(mut self, content: &str) -> Self {
73        self.content = Some(content.to_string());
74        self
75    }
76
77    pub fn filename(mut self, filename: &str) -> Self {
78        self.filepath = Some(filename.to_string());
79        self
80    }
81
82    pub fn format(mut self, format: FileFormat) -> Self {
83        self.format = Some(format);
84        self
85    }
86
87    pub fn parse(self) -> Result<Vec<Transaction>, StatementParseError> {
88        self.parse_into::<Transaction>()
89    }
90
91    pub fn parse_into<T>(self) -> Result<Vec<T>, StatementParseError>
92    where
93        T: TryFrom<ParsedTransaction, Error = StatementParseError>,
94    {
95        let format = self.format.map(Ok).unwrap_or_else(|| {
96            FileFormat::detect(self.filepath.as_deref(), self.content.as_deref())
97        })?;
98
99        let content = self.content.map(Ok).unwrap_or_else(|| {
100            self.filepath
101                .ok_or(StatementParseError::MissingContentAndFilepath)
102                .and_then(|path| fs::read_to_string(path).map_err(Into::into))
103        })?;
104
105        format.parse(&content)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use rstest::rstest;
113    use rust_decimal::Decimal;
114    use std::str::FromStr;
115
116    const SAMPLE_QFX: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
117<OFX>
118    <BANKMSGSRSV1>
119        <STMTTRNRS>
120            <STMTRS>
121                <BANKTRANLIST>
122                    <STMTTRN>
123                        <TRNTYPE>DEBIT</TRNTYPE>
124                        <DTPOSTED>20251226120000</DTPOSTED>
125                        <TRNAMT>-50.00</TRNAMT>
126                        <FITID>202512260</FITID>
127                        <NAME>Coffee Shop</NAME>
128                        <MEMO>Morning coffee</MEMO>
129                    </STMTTRN>
130                </BANKTRANLIST>
131            </STMTRS>
132        </STMTTRNRS>
133    </BANKMSGSRSV1>
134</OFX>"#;
135
136    #[test]
137    fn test_builder_missing_content() {
138        let result: Result<Vec<Transaction>, _> = ParserBuilder::new().parse();
139        assert!(matches!(
140            result,
141            Err(StatementParseError::UnsupportedFormat)
142        ));
143    }
144
145    #[test]
146    fn test_builder_with_format() {
147        let builder = ParserBuilder::new().content("test").format(FileFormat::Qfx);
148
149        assert!(builder.format.is_some());
150        assert_eq!(builder.format.unwrap(), FileFormat::Qfx);
151    }
152
153    #[test]
154    fn test_builder_new() {
155        let builder = ParserBuilder::new();
156        assert!(builder.content.is_none());
157        assert!(builder.filepath.is_none());
158        assert!(builder.format.is_none());
159    }
160
161    #[test]
162    fn test_builder_default() {
163        let builder = ParserBuilder::default();
164        assert!(builder.content.is_none());
165        assert!(builder.filepath.is_none());
166        assert!(builder.format.is_none());
167    }
168
169    #[test]
170    fn test_builder_content() {
171        let builder = ParserBuilder::new().content("test content");
172        assert_eq!(builder.content.unwrap(), "test content");
173    }
174
175    #[test]
176    fn test_builder_filename() {
177        let builder = ParserBuilder::new().filename("test.qfx");
178        assert_eq!(builder.filepath.unwrap(), "test.qfx");
179    }
180
181    #[test]
182    fn test_builder_chaining() {
183        let builder = ParserBuilder::new()
184            .content("content")
185            .filename("file.qfx")
186            .format(FileFormat::Qfx);
187
188        assert!(builder.content.is_some());
189        assert!(builder.filepath.is_some());
190        assert!(builder.format.is_some());
191    }
192
193    #[rstest]
194    #[case(Some(FileFormat::Qfx), None, "Explicit format")]
195    #[case(None, None, "Auto-detect by content")]
196    #[case(None, Some("statement.qfx"), "Auto-detect by filename")]
197    #[case(None, Some("statement.ofx"), "Auto-detect by .ofx extension")]
198    fn test_parse_with_different_detection_methods(
199        #[case] format: Option<FileFormat>,
200        #[case] filename: Option<&str>,
201        #[case] _description: &str,
202    ) {
203        let mut builder = ParserBuilder::new().content(SAMPLE_QFX);
204
205        if let Some(fmt) = format {
206            builder = builder.format(fmt);
207        }
208        if let Some(fname) = filename {
209            builder = builder.filename(fname);
210        }
211
212        let result = builder.parse();
213        assert!(result.is_ok());
214
215        let transactions = result.unwrap();
216        assert_eq!(transactions.len(), 1);
217        assert_eq!(transactions[0].transaction_type, "DEBIT");
218    }
219
220    #[test]
221    fn test_parse_raw_to_qfx_transaction() {
222        let result = FileFormat::Qfx.parse_raw(SAMPLE_QFX);
223
224        assert!(result.is_ok());
225        let parsed = result.unwrap();
226        assert_eq!(parsed.len(), 1);
227
228        match &parsed[0] {
229            ParsedTransaction::Qfx(txn) => {
230                assert_eq!(txn.trn_type, "DEBIT");
231                assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
232            }
233        }
234    }
235
236    #[test]
237    fn test_parse_into_transaction() {
238        let result = ParserBuilder::new()
239            .content(SAMPLE_QFX)
240            .format(FileFormat::Qfx)
241            .parse_into::<Transaction>();
242
243        assert!(result.is_ok());
244        let transactions = result.unwrap();
245        assert_eq!(transactions.len(), 1);
246        assert_eq!(transactions[0].transaction_type, "DEBIT");
247    }
248
249    #[test]
250    fn test_parse_unsupported_format() {
251        let result = ParserBuilder::new()
252            .content("random content that's not OFX")
253            .parse();
254
255        assert!(result.is_err());
256        assert!(matches!(
257            result.unwrap_err(),
258            StatementParseError::UnsupportedFormat
259        ));
260    }
261
262    #[test]
263    fn test_parse_no_content_no_filepath() {
264        let result = ParserBuilder::new().format(FileFormat::Qfx).parse();
265
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_parse_invalid_content() {
271        let result = ParserBuilder::new()
272            .content("invalid QFX content")
273            .format(FileFormat::Qfx)
274            .parse();
275
276        assert!(result.is_err());
277    }
278
279    #[rstest]
280    #[case(None, Some(SAMPLE_QFX), true)] // Detect by content
281    #[case(Some("statement.qfx"), None, true)] // Detect by .qfx extension
282    #[case(Some("statement.ofx"), None, true)] // Detect by .ofx extension
283    #[case(Some("statement.QFX"), Some(SAMPLE_QFX), true)] // Case insensitive with content
284    #[case(Some("statement.OFX"), Some(SAMPLE_QFX), true)] // Case insensitive with content
285    #[case(Some("statement.csv"), Some("random content"), false)] // Unsupported
286    #[case(None, None, false)] // No input
287    #[case(Some("statement.txt"), Some("not ofx"), false)] // Unsupported content
288    fn test_file_format_detect(
289        #[case] filename: Option<&str>,
290        #[case] content: Option<&str>,
291        #[case] should_succeed: bool,
292    ) {
293        let result = FileFormat::detect(filename, content);
294        if should_succeed {
295            assert!(result.is_ok());
296            assert_eq!(result.unwrap(), FileFormat::Qfx);
297        } else {
298            assert!(result.is_err());
299            assert!(matches!(
300                result.unwrap_err(),
301                StatementParseError::UnsupportedFormat
302            ));
303        }
304    }
305
306    #[test]
307    fn test_file_format_parse_raw() {
308        let result = FileFormat::Qfx.parse_raw(SAMPLE_QFX);
309        assert!(result.is_ok());
310
311        let parsed = result.unwrap();
312        assert_eq!(parsed.len(), 1);
313
314        match &parsed[0] {
315            ParsedTransaction::Qfx(txn) => {
316                assert_eq!(txn.trn_type, "DEBIT");
317                assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
318            }
319        }
320    }
321
322    #[test]
323    fn test_file_format_parse() {
324        let result = FileFormat::Qfx.parse::<Transaction>(SAMPLE_QFX);
325        assert!(result.is_ok());
326
327        let transactions = result.unwrap();
328        assert_eq!(transactions.len(), 1);
329        assert_eq!(transactions[0].transaction_type, "DEBIT");
330    }
331
332    #[test]
333    fn test_parsed_transaction_qfx_variant() {
334        let qfx_txn = QfxTransaction {
335            trn_type: "DEBIT".to_string(),
336            dt_posted: "20251226120000".into(),
337            amount: Decimal::from_str("-50.00").unwrap(),
338            fitid: Some("123".to_string()),
339            name: Some("Test".to_string()),
340            memo: Some("Memo".to_string()),
341        };
342
343        let parsed = ParsedTransaction::Qfx(qfx_txn);
344
345        match parsed {
346            ParsedTransaction::Qfx(txn) => {
347                assert_eq!(txn.trn_type, "DEBIT");
348                assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
349            }
350        }
351    }
352
353    #[test]
354    fn test_parsed_transaction_serialization() {
355        let qfx_txn = QfxTransaction {
356            trn_type: "DEBIT".to_string(),
357            dt_posted: "20251226120000".into(),
358            amount: Decimal::from_str("-50.00").unwrap(),
359            fitid: Some("123".to_string()),
360            name: Some("Test".to_string()),
361            memo: None,
362        };
363
364        let parsed = ParsedTransaction::Qfx(qfx_txn);
365        let json = serde_json::to_string(&parsed).unwrap();
366        assert!(json.contains("DEBIT"));
367
368        let deserialized: ParsedTransaction = serde_json::from_str(&json).unwrap();
369        match deserialized {
370            ParsedTransaction::Qfx(txn) => {
371                assert_eq!(txn.trn_type, "DEBIT");
372            }
373        }
374    }
375
376    #[test]
377    fn test_file_format_serialization() {
378        let format = FileFormat::Qfx;
379        let json = serde_json::to_string(&format).unwrap();
380        assert!(json.contains("qfx"));
381
382        let deserialized: FileFormat = serde_json::from_str(&json).unwrap();
383        assert_eq!(deserialized, FileFormat::Qfx);
384    }
385
386    #[test]
387    fn test_file_format_debug() {
388        let format = FileFormat::Qfx;
389        let debug_str = format!("{:?}", format);
390        assert!(debug_str.contains("Qfx"));
391    }
392
393    #[test]
394    fn test_parsed_transaction_debug() {
395        let qfx_txn = QfxTransaction {
396            trn_type: "DEBIT".to_string(),
397            dt_posted: "20251226120000".into(),
398            amount: Decimal::from_str("-50.00").unwrap(),
399            fitid: None,
400            name: None,
401            memo: None,
402        };
403
404        let parsed = ParsedTransaction::Qfx(qfx_txn);
405        let debug_str = format!("{:?}", parsed);
406        assert!(debug_str.contains("Qfx"));
407    }
408
409    #[test]
410    fn test_parsed_transaction_clone() {
411        let qfx_txn = QfxTransaction {
412            trn_type: "DEBIT".to_string(),
413            dt_posted: "20251226120000".into(),
414            amount: Decimal::from_str("-50.00").unwrap(),
415            fitid: None,
416            name: None,
417            memo: None,
418        };
419
420        let parsed = ParsedTransaction::Qfx(qfx_txn);
421        let cloned = parsed.clone();
422
423        match (parsed, cloned) {
424            (ParsedTransaction::Qfx(a), ParsedTransaction::Qfx(b)) => {
425                assert_eq!(a.trn_type, b.trn_type);
426                assert_eq!(a.amount, b.amount);
427            }
428        }
429    }
430
431    #[test]
432    fn test_builder_parse_invalid_qfx() {
433        let invalid_qfx = r#"<?xml version="1.0" encoding="UTF-8"?>
434<OFX>
435    <BANKMSGSRSV1>
436        <STMTTRNRS>
437            <STMTRS>
438                <BANKTRANLIST>
439                    <STMTTRN>
440                        <TRNTYPE>DEBIT</TRNTYPE>
441                        <DTPOSTED>20251226120000</DTPOSTED>
442                        <TRNAMT>invalid</TRNAMT>
443                    </STMTTRN>
444                </BANKTRANLIST>
445            </STMTRS>
446        </STMTTRNRS>
447    </BANKMSGSRSV1>
448</OFX>"#;
449
450        let result = ParserBuilder::new()
451            .content(invalid_qfx)
452            .format(FileFormat::Qfx)
453            .parse();
454
455        assert!(result.is_err());
456    }
457}