use crate::{ImportResult, Importer};
use anyhow::{Context, Result};
use chrono::{Datelike, NaiveDate};
use rustledger_core::{Amount, Directive, Posting, Transaction};
use std::fs;
use std::path::Path;
pub struct OfxImporter {
account: String,
default_currency: String,
}
impl OfxImporter {
pub fn new(account: impl Into<String>, default_currency: impl Into<String>) -> Self {
Self {
account: account.into(),
default_currency: default_currency.into(),
}
}
pub fn extract_from_string(&self, content: &str) -> Result<ImportResult> {
let ofx: ofxy::Ofx = content
.parse()
.with_context(|| "Failed to parse OFX content")?;
let mut directives = Vec::new();
let mut warnings = Vec::new();
if let Some(bank_msg) = &ofx.body.bank {
let stmt = &bank_msg.transaction_response.statement;
let currency = &stmt.currency;
if let Some(txn_list) = &stmt.bank_transactions {
for txn in &txn_list.transactions {
match self.parse_transaction(txn, currency) {
Ok(t) => directives.push(Directive::Transaction(t)),
Err(e) => warnings.push(format!("Skipped transaction: {e}")),
}
}
}
}
if let Some(cc_msg) = &ofx.body.credit_card {
let stmt = &cc_msg.transaction_response.statement;
let currency = &stmt.currency;
if let Some(txn_list) = &stmt.bank_transactions {
for txn in &txn_list.transactions {
match self.parse_transaction(txn, 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)
}
fn parse_transaction(
&self,
txn: &ofxy::body::Transaction,
currency: &str,
) -> Result<Transaction> {
let date = NaiveDate::from_ymd_opt(
txn.date_posted.year(),
txn.date_posted.month(),
txn.date_posted.day(),
)
.with_context(|| "Invalid date")?;
let 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 = txn.currency.as_ref().map_or_else(
|| {
if currency.is_empty() {
self.default_currency.clone()
} else {
currency.to_string()
}
},
|c| c.symbol.clone(),
);
let units = Amount::new(amount, &curr);
let posting = Posting::new(&self.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_posting(posting)
.with_posting(contra_posting);
if !name.is_empty() && !memo.is_empty() {
txn_builder = txn_builder.with_payee(name);
}
Ok(txn_builder)
}
}
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) -> Result<ImportResult> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read: {}", path.display()))?;
self.extract_from_string(&content)
}
fn description(&self) -> &'static str {
"Open Financial Exchange (OFX/QFX) file importer"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ofx_importer_new() {
let importer = OfxImporter::new("Assets:Bank", "USD");
assert_eq!(importer.account, "Assets:Bank");
assert_eq!(importer.default_currency, "USD");
}
#[test]
fn test_ofx_importer_name() {
let importer = OfxImporter::new("Assets:Bank", "USD");
assert_eq!(importer.name(), "OFX/QFX");
}
#[test]
fn test_ofx_importer_description() {
let importer = OfxImporter::new("Assets:Bank", "USD");
assert_eq!(
importer.description(),
"Open Financial Exchange (OFX/QFX) file importer"
);
}
#[test]
fn test_ofx_importer_identify() {
let importer = OfxImporter::new("Assets:Bank", "USD");
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::new("Assets:Bank", "USD");
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 importer = OfxImporter::new("Assets:Bank:Checking", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert_eq!(import_result.directives.len(), 2);
assert!(import_result.warnings.is_empty());
}
Err(e) => {
println!("OFX parse error (expected with minimal test data): {e}");
}
}
}
#[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 importer = OfxImporter::new("Liabilities:CreditCard", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert_eq!(import_result.directives.len(), 1);
}
Err(e) => {
println!("OFX parse error (expected with minimal test data): {e}");
}
}
}
#[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 importer = OfxImporter::new("Assets:Bank:Checking", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert!(import_result.directives.is_empty());
}
Err(e) => {
println!("OFX parse error: {e}");
}
}
}
#[test]
fn test_ofx_importer_invalid_content() {
let importer = OfxImporter::new("Assets:Bank", "USD");
let result = importer.extract_from_string("not valid ofx");
assert!(result.is_err());
}
#[test]
fn test_ofx_importer_extract_nonexistent_file() {
let importer = OfxImporter::new("Assets:Bank", "USD");
let result = importer.extract(Path::new("/nonexistent/file.ofx"));
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 importer = OfxImporter::new("Assets:Bank:Checking", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert_eq!(import_result.directives.len(), 1);
}
Err(e) => {
println!("OFX parse error: {e}");
}
}
}
#[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 importer = OfxImporter::new("Assets:Bank:Checking", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert_eq!(import_result.directives.len(), 1);
}
Err(e) => {
println!("OFX parse error: {e}");
}
}
}
#[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 importer = OfxImporter::new("Assets:Bank:Checking", "USD");
let result = importer.extract_from_string(ofx_content);
match &result {
Ok(import_result) => {
assert_eq!(import_result.directives.len(), 1);
}
Err(e) => {
println!("OFX parse error: {e}");
}
}
}
#[test]
fn test_ofx_importer_default_currency_fallback() {
let importer = OfxImporter::new("Assets:Bank", "EUR");
assert_eq!(importer.default_currency, "EUR");
}
}