A Rust library for parsing Open Financial Exchange (OFX) documents into strongly-typed structures.
ofx-rs handles both OFX 1.x (SGML) and OFX 2.x (XML) formats through a single entry point, producing precise domain types that prevent common financial data bugs at compile time. The parser is pure -- no I/O, no unsafe code, no runtime surprises.
Add the dependency to your project:
cargo add ofx-rsParse an OFX document with a single function call:
use ofx_rs::parse;
// You provide the file content; the library does no I/O itself.
let ofx_content = std::fs::read_to_string("statement.ofx").unwrap();
let doc = parse(&ofx_content).unwrap();
// Check signon status
assert!(doc.signon().status().is_success());
// Access the first bank statement
let banking = doc.banking().expect("no banking data");
let stmt = banking.statement_responses()[0]
.response()
.expect("no statement response");
println!("Account: {}", stmt.bank_account().account_id().as_str());
println!("Currency: {}", stmt.currency_default().as_str());
if let Some(balance) = stmt.ledger_balance() {
println!("Balance: {}", balance.amount());
}The library parses both major OFX format families transparently:
- OFX 2.x (XML) -- Well-formed XML with a processing instruction header (
<?OFX ... ?>). Parsed directly by quick-xml. - OFX 1.x (SGML) -- Uses SGML where closing tags are optional. The library normalizes these documents to well-formed XML automatically before parsing, using knowledge of the OFX tag hierarchy to insert missing close tags.
Format detection is automatic based on the header. You always call parse() the same way regardless of version.
use ofx_rs::parse;
use ofx_rs::types::TransactionType;
let doc = parse(&ofx_content)?;
let banking = doc.banking().expect("no banking data");
let stmt = banking.statement_responses()[0]
.response()
.expect("no statement response");
// Account information
let acct = stmt.bank_account();
println!("Bank: {}", acct.bank_id().as_str());
println!("Account: {}", acct.account_id().as_str());
println!("Type: {}", acct.account_type());
// Iterate and filter transactions
if let Some(txn_list) = stmt.transaction_list() {
for txn in txn_list.transactions() {
println!(
"{} {:>10} {}",
txn.date_posted(),
txn.amount(),
txn.name().unwrap_or("(unnamed)")
);
}
let debits: Vec<_> = txn_list
.transactions()
.iter()
.filter(|t| t.transaction_type() == TransactionType::Debit)
.collect();
println!("Found {} debits", debits.len());
}use ofx_rs::parse;
let doc = parse(&ofx_content)?;
if let Some(cc) = doc.credit_card() {
let stmt = cc.statement_responses()[0]
.response()
.expect("no cc statement");
println!("Card: {}", stmt.credit_card_account().account_id().as_str());
if let Some(balance) = stmt.ledger_balance() {
println!("Balance: {}", balance.amount());
}
}OFX 1.x files use SGML syntax where closing tags are optional:
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
<DTSERVER>20230115
<LANGUAGE>ENG
The library normalizes this automatically. The API is identical regardless of format version:
use ofx_rs::parse;
// Works the same for both OFX 1.x and 2.x
let doc = parse(&sgml_content)?;
println!("OFX version: {}", doc.header().version());parse() returns an OfxDocument that mirrors the OFX document tree:
OfxDocument
|-- header: OfxHeader (version, security level, file UIDs)
|-- signon: SignonResponse (status, server datetime, language, FI info)
|-- banking: BankingMessageSet (optional)
| |-- statement_responses: Vec<TransactionWrapper<StatementResponse>>
| |-- status: Status
| |-- response: StatementResponse
| |-- currency_default: CurrencyCode
| |-- bank_account: BankAccount
| |-- transaction_list: TransactionList (optional)
| | |-- transactions: Vec<StatementTransaction>
| |-- ledger_balance: LedgerBalance (optional)
| |-- available_balance: AvailableBalance (optional)
| |-- balance_list: Vec<Balance>
|-- credit_card: CreditCardMessageSet (optional)
|-- statement_responses: Vec<TransactionWrapper<CcStatementResponse>>
|-- (same structure as banking, with CreditCardAccount)
Every field is accessed through methods on the returned structs. Optional fields return Option<&T>, and collections return slices.
Financial data demands precision. ofx-rs uses domain-specific types rather than raw strings and floats:
| Type | Wraps | Purpose |
|---|---|---|
OfxAmount |
rust_decimal::Decimal |
Exact financial arithmetic -- no floating-point rounding |
OfxDateTime |
time::OffsetDateTime |
OFX datetime format with timezone support |
CurrencyCode |
Validated String |
ISO 4217 currency codes (USD, EUR, BRL) |
TransactionType |
Enum (18 variants) | CREDIT, DEBIT, CHECK, ATM, POS, XFER, and more |
AccountType |
Enum | CHECKING, SAVINGS, MONEYMRKT, CREDITLINE |
BankId, AccountId, FitId |
Validated newtypes | Length-validated identifiers that reject empty strings |
CheckNumber |
Validated newtype | Check number with spec-compliant length constraints |
OfxAmount supports arithmetic operations (Add, Sub, Neg) and convenience methods like is_negative() and is_zero():
let amount: ofx_rs::types::OfxAmount = "-50.00".parse().unwrap();
assert!(amount.is_negative());
// Decimal precision preserved
assert_eq!(amount.as_decimal(), rust_decimal::Decimal::new(-5000, 2));OfxDateTime parses the OFX datetime format with right-truncation and timezone offsets:
// Date only
let dt: ofx_rs::types::OfxDateTime = "20230115".parse().unwrap();
// Full datetime with timezone
let dt: ofx_rs::types::OfxDateTime = "20230115120000[-5:EST]".parse().unwrap();All errors are structured and non-panicking. The top-level OfxError enum distinguishes three failure categories:
OfxError::Header-- The OFX header is missing, malformed, or contains an unrecognized version or security level.OfxError::Xml-- The XML body is malformed, a required element is missing, or an element contains invalid content.OfxError::Aggregate-- A required field within an OFX aggregate (like a transaction missing its FITID) is absent.
OfxError and its inner types are marked #[non_exhaustive], so match statements require a wildcard arm. Each variant provides specific context about what went wrong and where:
use ofx_rs::{parse, OfxError};
match parse(input) {
Ok(doc) => { /* use document */ }
Err(OfxError::Header(e)) => eprintln!("Bad header: {e}"),
Err(OfxError::Xml(e)) => eprintln!("XML error: {e}"),
Err(OfxError::Aggregate(e)) => eprintln!("Aggregate error: {e}"),
Err(e) => eprintln!("Other error: {e}"),
}ofx-rs depends on three crates, chosen for correctness over convenience:
- quick-xml -- Fast, zero-copy XML parsing
- rust_decimal -- Exact decimal arithmetic for financial amounts
- time -- Date and time handling with timezone support
No runtime, no async, no macros, no build scripts.
This crate uses edition = "2024" and requires Rust 1.85 or later.
MIT