OFX

A Ruby gem for parsing OFX (Open Financial Exchange) files. Supports OFX 1.x (SGML) and OFX 2.x (XML), bank statements and credit card statements, with a fluent API and configurable field mappings.

Installation

Add to your Gemfile:

gem "ofx_kit"

Usage

Basic parsing

# Parse from a file path
ofx = OFX.new("statement.ofx")

# Parse from an IO object
ofx = OFX.new(File.open("statement.ofx"))
ofx = OFX.new(StringIO.new(raw_content))

# Block form — yields the parser
OFX.new("statement.ofx") do |p|
  puts p..
end

Accessing data

ofx.filename      # => "statement.ofx" (nil for IO inputs without a path)
ofx.headers       # => { "VERSION" => "102", "ENCODING" => "USASCII", ... }

# Single-statement files
ofx.       # => OFX::BankAccount or OFX::CreditCardAccount
ofx.transactions  # => Array of OFX::Transaction
ofx.balance       # => OFX::Balance

# Multiple-statement files — use the plural forms
ofx.accounts      # => [OFX::BankAccount, ...]
ofx.statements    # => [OFX::BankStatement, ...]
ofx.balances      # => [OFX::Balance, ...]

Transactions

t = ofx.transactions.first

t.fit_id        # => "20240115001"       String
t.type          # => "DEBIT"             String
t.memo          # => "Pagamento boleto"  String
t.posted_at     # => Time object
t.amount        # => Money object (positive = credit, negative = debit)
t.amount_cents  # => Integer (same as t.amount.fractional)

t.amount.currency.iso_code  # => "BRL"
t.amount.to_d               # => BigDecimal("-150.50")

Credits, debits, and scopes

stmt.transactions and account.transactions both return an OFX::TransactionCollection with .credits, .debits, length, and the full Enumerable API:

stmt = ofx.statements.first
txns = stmt.transactions           # => OFX::TransactionCollection

txns.length                        # => 2
txns.credits                       # => TransactionCollection of positive amounts
txns.debits                        # => TransactionCollection of negative amounts
txns.total_credits                 # => Money (sum of positive transactions)
txns.total_debits                  # => Money (sum of negative transactions)
txns.net                           # => Money (total_credits + total_debits)
txns.map(&:memo)                   # => ["Pagamento boleto", "Deposito salario"]
txns.sort_by(&:posted_at)          # => Array, sorted by date

Balance

bal = ofx.balance
bal.amount        # => Money object
bal.amount_cents  # => Integer
bal.posted_at     # => Time object

Summary

ofx.summary
# => {
#   headers: { "VERSION" => "102", ... },
#   statements: {
#     "12345-6" => {
#       currency:      "BRL",
#       transactions:  { count: 2, net_cents: 284_950 },
#       credits:       { count: 1, total_cents: 300_000 },
#       debits:        { count: 1, total_cents: -15_050 },
#       balance_cents: 500_000
#     }
#   }
# }

Error handling

OFX.new("missing.ofx")      # => Errno::ENOENT
OFX.new("bad_header.ofx")   # => OFX::InvalidHeaderError
OFX.new("bad_xml.ofx")      # => OFX::InvalidBodyError
OFX.new(42)                 # => ArgumentError

# Calling #account or #balance on a multi-statement file:
ofx.   # => OFX::MultipleStatementsError (use `accounts`)
ofx.balance   # => OFX::MultipleStatementsError (use `balances`)

Configuration

Use map to bind OFX XML tags to Ruby attribute names of your choice.

Adding new fields

Map proprietary XML tags that your bank emits but the gem doesn't know about by default:

OFX.configure do |config|
  config..map "AGENCIA", to: "branch_code"
  config.transaction.map "HISPAYEEMEMO", to: "extended_memo"
end

ofx = OFX.new("statement.ofx")
ofx..branch_code              # => "0272"
ofx.transactions.first.extended_memo # => "Tarifa bancaria"

Renaming built-in fields

map renames the default attribute for any non-protected OFX tag:

OFX.configure do |config|
  config.transaction.map "FITID", to: "uid"   # default is fit_id
  config.transaction.map "NAME",  to: "payee_name"
end

ofx = OFX.new("statement.ofx")
ofx.transactions.first.uid        # => "20240115001"
ofx.transactions.first.payee_name # => "ACME Corp"
# ofx.transactions.first.fit_id  # => nil (FITID is now mapped to uid)

Protected core fields — The following OFX fields are used internally by the gem to build Money objects and parse dates. They cannot be renamed: CURDEF, TRNAMT, DTPOSTED, DTUSER, BALAMT, DTASOF. Attempting to remap them raises OFX::ConfigurationError.

Loading mappings from a YAML file

For larger configurations, use a YAML file instead of inline map calls:

OFX.configure do |config|
  config.load_mappings("config/ofx_mappings.yml")
end

The file must have a FIELDS: top-level key, with OFX section tags underneath. Each entry maps an XML tag to the Ruby attribute name you want to use:

FIELDS:
  STMTTRN:
    # New field: your bank emits HISPAYEEMEMO but the gem doesn't know it by default
    HISPAYEEMEMO: extended_memo      # → transaction.extended_memo

    # Override: rename a standard field to a name that fits your domain
    FITID: uid                       # → transaction.uid  (default was fit_id)

  BANKACCTFROM:
    # New field: Brazilian banks emit AGENCIA for the branch number
    AGENCIA: branch_code             # → account.branch_code

After loading:

ofx = OFX.new("statement.ofx")

# Custom fields — read proprietary XML tags as Ruby attributes
ofx.transactions.first.extended_memo  # => "Tarifa bancaria"
ofx..branch_code               # => "0272"

# Overridden field — standard tag mapped to a different name
ofx.transactions.first.uid            # => "20240115001"
# ofx.transactions.first.fit_id       # => nil (FITID is now mapped to uid)

Silencing warnings

transactions and balances aggregate across all statements in a multi-statement file and emit a warning. To silence them:

OFX.config.multi_statement_warnings = false

Default currency

When a TransactionCollection is empty and has no statement context (e.g. an in-memory collection built in tests), aggregation methods like total_credits and net need a currency to produce a Money.new(0, ...) value. The fallback is OFX.config.default_currency, which defaults to 'USD':

OFX.config.default_currency = 'BRL'

In normal usage this fallback is never reached — the gem wires every collection to its statement at parse time and reads the currency directly from statement.account.currency.

Rails

Field mappings — run the eject generator to copy the default mappings into your app:

rails generate ofx:eject

This creates config/initializers/ofx_mappings.yml with all default mappings. The OFX gem detects and loads this file automatically on boot — no initializer or OFX.configure call needed.

Edit the file to rename built-in fields or capture bank-specific XML tags:

# config/initializers/ofx_mappings.yml
FIELDS:
  STMTTRN:
    FITID: "uid"                      # transaction.fit_id → transaction.uid
    HISPAYEEMEMO: "extended_memo"     # → transaction.extended_memo (new field)
  BANKACCTFROM:
    AGENCIA: "branch_code"            # → account.branch_code (new field)

Behavioral options — create a standard initializer:

# config/initializers/ofx.rb
OFX.configure do |config|
  config.multi_statement_warnings = false  # silence aggregation warnings
end

Contributing

  1. Fork the repository and create a feature branch.
  2. Install dependencies:
   bundle install
  1. Make your changes. Add or update specs to cover them.
  2. Run the test suite and linter before opening a pull request:
   bundle exec rspec
   bundle exec rubocop

All tests must pass and RuboCop must report no offenses.

Testing locally via console

You can exercise the gem interactively using irb from the project root. The spec/fixtures/ directory contains sample OFX files ready to use.

bundle exec irb -r ./lib/ofx_kit
# Parse a bank statement (OFX 1.x)
ofx = OFX.new("spec/fixtures/bank_simple.ofx")
ofx..   # => "12345-6"
ofx.transactions.length  # => 2
ofx.balance.amount       # => Money object

# Parse an OFX 2.x file
ofx = OFX.new("spec/fixtures/bank_ofx2.ofx")
ofx.headers              # => { "VERSION" => "220", ... }

# Parse a credit card statement
ofx = OFX.new("spec/fixtures/credit_card.ofx")
ofx.              # => OFX::CreditCardAccount
ofx.transactions.first.amount.to_d  # => BigDecimal

# Multiple statements
ofx = OFX.new("spec/fixtures/bank_multiple.ofx")
ofx.accounts.length      # => 2
ofx.statements.map { |s| s.. }

# Try custom field mappings
OFX.configure do |config|
  config.transaction.map "HISPAYEEMEMO", to: "extended_memo"
end
ofx = OFX.new("spec/fixtures/bank_simple.ofx")
ofx.transactions.first.extended_memo
OFX.reset_config!  # restore defaults between tests

License

MIT