use crate::config::ImporterConfig;
use crate::{EnrichedImportResult, ImportResult, Importer};
use anyhow::{Context, Result};
use rustledger_core::NaiveDate;
use rustledger_core::{Amount, Directive, Posting, Transaction};
use rustledger_ops::enrichment::{CategorizationMethod, Enrichment};
use std::fs;
use std::path::Path;
#[derive(Debug, Default, Clone)]
pub struct OfxImporter;
impl OfxImporter {
pub fn extract_from_string(
&self,
content: &str,
config: &ImporterConfig,
) -> Result<ImportResult> {
let default_currency = config.currency.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"OFX import requires a default currency \
(set `ImporterConfig.currency = Some(...)`)"
)
})?;
let transactions = parse_ofx(content).with_context(|| "Failed to parse OFX content")?;
let mut directives = Vec::new();
let mut warnings = Vec::new();
for txn in &transactions {
let statement_currency = txn.statement_currency.as_deref().unwrap_or("");
match Self::build_transaction(
txn,
statement_currency,
&config.account,
default_currency,
) {
Ok(t) => directives.push(Directive::Transaction(t)),
Err(e) => warnings.push(format!("Skipped transaction: {e}")),
}
}
let mut result = ImportResult::new(directives);
for warning in warnings {
result = result.with_warning(warning);
}
Ok(result)
}
pub fn extract_from_string_enriched(
&self,
content: &str,
config: &ImporterConfig,
) -> Result<EnrichedImportResult> {
let result = self.extract_from_string(content, config)?;
let entries = result
.directives
.into_iter()
.enumerate()
.map(|(i, directive)| {
let fingerprint = crate::directive_fingerprint(&directive);
let enrichment = Enrichment {
directive_index: i,
confidence: 0.0,
method: CategorizationMethod::Default,
alternatives: vec![],
fingerprint,
};
(directive, enrichment)
})
.collect();
let mut enriched = EnrichedImportResult::new(entries);
for warning in result.warnings {
enriched = enriched.with_warning(warning);
}
Ok(enriched)
}
fn build_transaction(
txn: &OfxTransaction,
statement_currency: &str,
account: &str,
default_currency: &str,
) -> Result<Transaction> {
let date = ofx_date_to_naive(&txn.date_posted)?;
let amount: rust_decimal::Decimal = txn
.amount
.parse()
.with_context(|| format!("invalid amount: {:?}", txn.amount))?;
let name = txn.name.as_deref().unwrap_or("");
let memo = txn.memo.as_deref().unwrap_or("");
let narration = if memo.is_empty() {
name.to_string()
} else if name.is_empty() {
memo.to_string()
} else {
format!("{name} - {memo}")
};
let curr = match txn.currency.as_deref().filter(|c| !c.is_empty()) {
Some(c) => c.to_string(),
None if statement_currency.is_empty() => default_currency.to_string(),
None => statement_currency.to_string(),
};
let units = Amount::new(amount, &curr);
let posting = Posting::new(account, units);
let contra_account = if amount < rust_decimal::Decimal::ZERO {
"Expenses:Unknown"
} else {
"Income:Unknown"
};
let contra_posting = Posting::auto(contra_account);
let mut txn_builder = Transaction::new(date, &narration)
.with_flag('*')
.with_synthesized_posting(posting)
.with_synthesized_posting(contra_posting);
if !name.is_empty() && !memo.is_empty() {
txn_builder = txn_builder.with_payee(name);
}
Ok(txn_builder)
}
}
struct OfxTransaction {
date_posted: String,
amount: String,
name: Option<String>,
memo: Option<String>,
currency: Option<String>,
statement_currency: Option<String>,
}
fn parse_ofx(content: &str) -> Result<Vec<OfxTransaction>> {
let has_ofx_root = content.match_indices("<OFX").any(|(i, _)| {
content[i + 4..]
.chars()
.next()
.is_some_and(|c| c == '>' || c.is_whitespace())
});
if !has_ofx_root {
anyhow::bail!("not an OFX document (no <OFX> element)");
}
let mut curdefs: Vec<(usize, String)> = Vec::new();
let mut scan = 0;
while let Some(rel) = content[scan..].find("<CURDEF>") {
let i = scan + rel;
if let Some(v) = leaf(&content[i..], "CURDEF") {
curdefs.push((i, v));
}
scan = i + "<CURDEF>".len();
}
let mut transactions = Vec::new();
let mut cd = 0;
let mut statement_currency: Option<String> = None;
let mut scan = 0;
while let Some(rel) = content[scan..].find("<STMTTRN>") {
let start = scan + rel;
let after = start + "<STMTTRN>".len();
while cd < curdefs.len() && curdefs[cd].0 < start {
statement_currency = Some(curdefs[cd].1.clone());
cd += 1;
}
let rest = &content[after..];
let end = ["<STMTTRN>", "</STMTTRN>", "</BANKTRANLIST>"]
.iter()
.filter_map(|m| rest.find(m))
.min()
.unwrap_or(rest.len());
let block = &rest[..end];
transactions.push(OfxTransaction {
date_posted: leaf(block, "DTPOSTED").unwrap_or_default(),
amount: leaf(block, "TRNAMT").unwrap_or_default(),
name: leaf(block, "NAME"),
memo: leaf(block, "MEMO"),
currency: transaction_currency(block),
statement_currency: statement_currency.clone(),
});
scan = after;
}
Ok(transactions)
}
fn transaction_currency(block: &str) -> Option<String> {
let i = block.find("<CURRENCY>")?;
leaf(&block[i..], "CURSYM")
}
fn leaf(block: &str, tag: &str) -> Option<String> {
let prefix = format!("<{tag}");
let mut from = 0;
loop {
let i = from + block[from..].find(&prefix)?;
let rest = &block[i + prefix.len()..];
match rest.chars().next() {
Some('>') => {
let after = &rest['>'.len_utf8()..];
let end = after.find('<').unwrap_or(after.len());
return Some(decode_entities(after[..end].trim()));
}
Some('/' | ' ' | '\t' | '\r' | '\n') => {
let gt = rest.find('>')?;
if rest[..gt].ends_with('/') {
return Some(String::new()); }
let after = &rest[gt + 1..];
let end = after.find('<').unwrap_or(after.len());
return Some(decode_entities(after[..end].trim()));
}
_ => from = i + prefix.len(),
}
}
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
fn ofx_date_to_naive(s: &str) -> Result<NaiveDate> {
let s = s.trim();
if s.len() < 8 || !s.as_bytes()[..8].iter().all(u8::is_ascii_digit) {
anyhow::bail!("invalid OFX date: {s:?}");
}
format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
.parse()
.with_context(|| format!("invalid OFX date: {s:?}"))
}
impl Importer for OfxImporter {
fn name(&self) -> &'static str {
"OFX/QFX"
}
fn identify(&self, path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ofx") || ext.eq_ignore_ascii_case("qfx"))
}
fn extract(&self, path: &Path, config: &ImporterConfig) -> Result<ImportResult> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read: {}", path.display()))?;
self.extract_from_string(&content, config)
}
fn extract_enriched(
&self,
path: &Path,
config: &ImporterConfig,
) -> Result<EnrichedImportResult> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read: {}", path.display()))?;
self.extract_from_string_enriched(&content, config)
}
fn description(&self) -> &'static str {
"Open Financial Exchange (OFX/QFX) file importer"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CsvConfig, ImporterType};
fn ofx_cfg(account: &str, currency: &str) -> ImporterConfig {
ImporterConfig {
account: account.to_string(),
currency: Some(currency.to_string()),
importer_type: ImporterType::Csv(CsvConfig::default()),
}
}
#[test]
fn test_ofx_importer_name() {
let importer = OfxImporter;
assert_eq!(importer.name(), "OFX/QFX");
}
#[test]
fn test_ofx_importer_description() {
let importer = OfxImporter;
assert_eq!(
importer.description(),
"Open Financial Exchange (OFX/QFX) file importer"
);
}
#[test]
fn test_ofx_importer_identify() {
let importer = OfxImporter;
assert!(importer.identify(Path::new("statement.ofx")));
assert!(importer.identify(Path::new("statement.OFX")));
assert!(importer.identify(Path::new("statement.qfx")));
assert!(importer.identify(Path::new("statement.QFX")));
assert!(!importer.identify(Path::new("statement.csv")));
assert!(!importer.identify(Path::new("statement.pdf")));
assert!(!importer.identify(Path::new("ofx"))); }
#[test]
fn test_ofx_importer_identify_no_extension() {
let importer = OfxImporter;
assert!(!importer.identify(Path::new("statement")));
}
#[test]
fn test_ofx_importer_extract() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240115
<TRNAMT>-50.00
<FITID>2024011501
<NAME>GROCERY STORE
<MEMO>Weekly groceries
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20240120
<TRNAMT>1500.00
<FITID>2024012001
<NAME>EMPLOYER INC
<MEMO>Salary payment
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Assets:Bank:Checking", "USD"));
let import_result = result.expect("OFX content should parse");
assert_eq!(import_result.directives.len(), 2);
assert!(import_result.warnings.is_empty());
}
#[test]
fn test_ofx_importer_credit_card() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<CREDITCARDMSGSRSV1>
<CCSTMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<CCSTMTRS>
<CURDEF>USD
<CCACCTFROM>
<ACCTID>1234567890123456
</CCACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240110
<TRNAMT>-25.50
<FITID>2024011001
<NAME>RESTAURANT
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>-250.00
<DTASOF>20240131
</LEDGERBAL>
</CCSTMTRS>
</CCSTMTTRNRS>
</CREDITCARDMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Liabilities:CreditCard", "USD"));
let import_result = result.expect("OFX content should parse");
assert_eq!(import_result.directives.len(), 1);
}
#[test]
fn test_ofx_importer_empty_bank_list() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Assets:Bank:Checking", "USD"));
let import_result = result.expect("OFX content should parse");
assert!(import_result.directives.is_empty());
}
#[test]
fn test_ofx_importer_invalid_content() {
let importer = OfxImporter;
let result = importer.extract_from_string("not valid ofx", &ofx_cfg("Assets:Bank", "USD"));
assert!(result.is_err());
}
#[test]
fn test_ofx_importer_extract_nonexistent_file() {
use crate::config::{CsvConfig, ImporterType};
let importer = OfxImporter;
let config = ImporterConfig {
account: "Assets:Bank".into(),
currency: Some("USD".into()),
importer_type: ImporterType::Csv(CsvConfig::default()),
};
let result = importer.extract(Path::new("/nonexistent/file.ofx"), &config);
assert!(result.is_err());
}
#[test]
fn test_ofx_importer_transaction_name_only() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240115
<TRNAMT>-50.00
<FITID>2024011501
<NAME>GROCERY STORE
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Assets:Bank:Checking", "USD"));
let import_result = result.expect("OFX content should parse");
assert_eq!(import_result.directives.len(), 1);
}
#[test]
fn test_ofx_importer_transaction_memo_only() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240115
<TRNAMT>-50.00
<FITID>2024011501
<MEMO>Payment for services
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Assets:Bank:Checking", "USD"));
let import_result = result.expect("OFX content should parse");
assert_eq!(import_result.directives.len(), 1);
}
#[test]
fn test_ofx_importer_income_transaction() {
let ofx_content = r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20240120
<TRNAMT>1500.00
<FITID>2024012001
<NAME>EMPLOYER INC
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>";
let result =
OfxImporter.extract_from_string(ofx_content, &ofx_cfg("Assets:Bank:Checking", "USD"));
let import_result = result.expect("OFX content should parse");
assert_eq!(import_result.directives.len(), 1);
}
#[test]
fn test_ofx_importer_missing_currency_errors() {
let cfg = ImporterConfig {
account: "Assets:Bank".into(),
currency: None,
importer_type: crate::config::ImporterType::Csv(crate::config::CsvConfig::default()),
};
let result =
OfxImporter.extract_from_string("not OFX, but the currency check runs first", &cfg);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("requires a default currency"),
"expected currency error, got: {msg}"
);
}
#[test]
fn test_native_1x_sparse_headers() {
let ofx = "OFXHEADER:100\nDATA:OFXSGML\nVERSION:102\nSECURITY:NONE\nENCODING:USASCII\n\n\
<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD<BANKACCTFROM><ACCTID>1</BANKACCTFROM>\n\
<BANKTRANLIST>\n\
<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20240115<TRNAMT>-50.00<FITID>t1<NAME>COFFEE SHOP</STMTTRN>\n\
</BANKTRANLIST></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("sparse 1.x headers must parse");
assert_eq!(r.directives.len(), 1);
let Directive::Transaction(txn) = &r.directives[0] else {
panic!("expected transaction");
};
assert_eq!(txn.narration.as_str(), "COFFEE SHOP");
assert_eq!(txn.postings[0].account.as_str(), "Assets:Bank");
}
#[test]
fn test_native_2x_xml_with_entities_and_self_closing() {
let ofx = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<?OFX OFXHEADER=\"200\" VERSION=\"200\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>\n\
<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD</CURDEF>\n\
<BANKTRANLIST>\n\
<STMTTRN><TRNTYPE>DEBIT</TRNTYPE><DTPOSTED>20240115120000.000[-5:EST]</DTPOSTED><TRNAMT>-50.00</TRNAMT><FITID>t1</FITID><NAME>Johnson & Co</NAME><MEMO/></STMTTRN>\n\
</BANKTRANLIST></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("2.x XML must parse");
assert_eq!(r.directives.len(), 1);
let Directive::Transaction(txn) = &r.directives[0] else {
panic!("expected transaction");
};
assert_eq!(txn.narration.as_str(), "Johnson & Co");
assert_eq!(txn.date, rustledger_core::naive_date(2024, 1, 15).unwrap());
}
#[test]
fn test_native_multi_statement_currency() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240101<TRNAMT>-1.00<NAME>A</STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1>\n\
<CREDITCARDMSGSRSV1><CCSTMTTRNRS><CCSTMTRS><CURDEF>CAD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240102<TRNAMT>-2.00<NAME>B</STMTTRN></BANKTRANLIST>\n\
</CCSTMTRS></CCSTMTTRNRS></CREDITCARDMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("multi-statement must parse");
assert_eq!(r.directives.len(), 2);
let curr = |d: &Directive| match d {
Directive::Transaction(t) => t.postings[0].amount().unwrap().currency.to_string(),
_ => panic!("expected transaction"),
};
assert_eq!(curr(&r.directives[0]), "USD");
assert_eq!(curr(&r.directives[1]), "CAD");
}
#[test]
fn test_native_leaf_and_helpers() {
assert_eq!(leaf("<NAME>Foo<MEMO>Bar", "NAME").as_deref(), Some("Foo"));
assert_eq!(leaf("<NAME>Foo</NAME>", "NAME").as_deref(), Some("Foo"));
assert_eq!(leaf("<MEMO/>", "MEMO").as_deref(), Some(""));
assert_eq!(leaf("<NAME>x", "MEMO"), None);
assert_eq!(leaf("<MEMO />", "MEMO").as_deref(), Some(""));
assert_eq!(leaf("<MEMO x=\"1\"/>", "MEMO").as_deref(), Some(""));
assert_eq!(leaf("<NAME id=\"1\">Foo<", "NAME").as_deref(), Some("Foo"));
assert_eq!(
leaf("<NAMEEXTRA>Z<NAME>Foo", "NAME").as_deref(),
Some("Foo")
);
assert_eq!(decode_entities("a & b <c>"), "a & b <c>");
assert_eq!(
ofx_date_to_naive("20240115120000[-5:EST]").unwrap(),
rustledger_core::naive_date(2024, 1, 15).unwrap()
);
assert!(ofx_date_to_naive("2024").is_err());
}
fn first_posting_currency(r: &ImportResult) -> String {
match &r.directives[0] {
Directive::Transaction(t) => t.postings[0].amount().unwrap().currency.to_string(),
_ => panic!("expected transaction"),
}
}
#[test]
fn test_native_transaction_cursym_overrides_statement() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240115<TRNAMT>-50.00<NAME>X\n\
<CURRENCY><CURRATE>1.1<CURSYM>EUR</CURRENCY></STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("must parse");
assert_eq!(first_posting_currency(&r), "EUR");
}
#[test]
fn test_native_origcurrency_does_not_override() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240115<TRNAMT>-50.00<NAME>FOREIGN\n\
<ORIGCURRENCY><CURRATE>0.8<CURSYM>GBP</ORIGCURRENCY></STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("must parse");
assert_eq!(first_posting_currency(&r), "USD");
}
#[test]
fn test_native_sgml_without_aggregate_close_tags() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST>\n\
<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20240115<TRNAMT>-50.00<NAME>A\n\
<STMTTRN><TRNTYPE>CREDIT<DTPOSTED>20240116<TRNAMT>60.00<NAME>B\n\
</BANKTRANLIST></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("end-tag-less SGML must parse");
assert_eq!(r.directives.len(), 2);
}
#[test]
fn test_native_2x_credit_card() {
let ofx = "<?xml version=\"1.0\"?><?OFX OFXHEADER=\"200\" VERSION=\"200\"?>\n\
<OFX><CREDITCARDMSGSRSV1><CCSTMTTRNRS><CCSTMTRS><CURDEF>USD</CURDEF>\n\
<BANKTRANLIST><STMTTRN><TRNTYPE>DEBIT</TRNTYPE><DTPOSTED>20240110</DTPOSTED><TRNAMT>-25.50</TRNAMT><NAME>SHOP</NAME></STMTTRN></BANKTRANLIST>\n\
</CCSTMTRS></CCSTMTTRNRS></CREDITCARDMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Liabilities:Card", "USD"))
.expect("2.x credit card must parse");
assert_eq!(r.directives.len(), 1);
}
#[test]
fn test_native_no_name_no_memo() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240115<TRNAMT>-50.00</STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("must parse");
assert_eq!(r.directives.len(), 1);
let Directive::Transaction(t) = &r.directives[0] else {
panic!("expected transaction");
};
assert_eq!(t.narration.as_str(), "");
}
#[test]
fn test_native_malformed_amount_warns() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240115<TRNAMT>not-a-number<NAME>X</STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("parse succeeds; the bad txn is skipped");
assert!(r.directives.is_empty());
assert_eq!(r.warnings.len(), 1);
assert!(r.warnings[0].contains("invalid amount"));
}
#[test]
fn test_native_date_is_local_not_utc() {
let ofx = "<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><CURDEF>USD\n\
<BANKTRANLIST><STMTTRN><DTPOSTED>20240115230000[-5:EST]<TRNAMT>-50.00<NAME>LATE</STMTTRN></BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
let r = OfxImporter
.extract_from_string(ofx, &ofx_cfg("Assets:Bank", "USD"))
.expect("must parse");
let Directive::Transaction(t) = &r.directives[0] else {
panic!("expected transaction");
};
assert_eq!(t.date, rustledger_core::naive_date(2024, 1, 15).unwrap());
}
}