ofx-rs 0.2.0

A Rust library for parsing Open Financial Exchange (OFX) documents
Documentation

ofx-rs

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.

Quick Start

Add the dependency to your project:

cargo add ofx-rs

Parse 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());
}

Supported Formats

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.

Examples

Iterating Over Transactions

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());
}

Parsing a Credit Card Statement

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());
    }
}

Handling OFX 1.x (SGML) Files

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());

API Overview

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.

Type System

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();

Error Handling

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}"),
}

Dependencies

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.

Minimum Supported Rust Version

This crate uses edition = "2024" and requires Rust 1.85 or later.

License

MIT