ofx 0.3.1 → 0.3.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a1f7e1315a8e1fe59c9e97b5bb1bd470f5ba977463f3311e3eb76ecdc707afd7
4
+ data.tar.gz: f8463316d6ef710908701765feabbd884e7f5ee87b0ceb2188cef1284705ed23
5
+ SHA512:
6
+ metadata.gz: 4883a274bcb8fcbd35b363183f9ddf896994278ade919f038d698ccd1164dd78b947ee500d3b66b27dc6c22ba719ba6144317a1fbacf66a724d35e3bc14845bd
7
+ data.tar.gz: 7f40b8abdde571151fe54015a243e8f569116178dc7ed0113639900bbfe4e976ca4f62ccb32dbdaa72e2ce5ddd5794fd85f50bf1dc455ac20a61c893c0e5dd49
data/README.rdoc CHANGED
@@ -1,8 +1,11 @@
1
1
  = OFX
2
2
 
3
- A simple OFX (Open Financial Exchange) parser built on top of Nokogiri. Currently supports OFX 1.0.2.
3
+ {<img src="https://badge.fury.io/rb/ofx.png" alt="Gem Version" />}[http://badge.fury.io/rb/ofx]
4
+ {<img src="https://travis-ci.org/annacruz/ofx.svg?branch=master" alt="Build Status" />}[https://travis-ci.org/annacruz/ofx]
4
5
 
5
- Works on both Ruby 1.8 and 1.9.
6
+ A simple OFX (Open Financial Exchange) parser built on top of Nokogiri. Currently supports both OFX 1.0.2 and 2.1.1.
7
+
8
+ Works on both ruby 1.9 and 2.0.
6
9
 
7
10
  == Usage
8
11
 
@@ -14,10 +17,16 @@ Works on both Ruby 1.8 and 1.9.
14
17
  p account.transactions
15
18
  end
16
19
 
17
- == Maintainer
20
+ Invalid files will raise an OFX::UnsupportedFileError.
21
+
22
+ == Creator
18
23
 
19
24
  * Nando Vieira - http://simplesideias.com.br
20
25
 
26
+ == Maintainer
27
+
28
+ * Anna Cruz - http://anna-cruz.com
29
+
21
30
  == License
22
31
 
23
32
  (The MIT License)
data/Rakefile CHANGED
@@ -3,3 +3,5 @@ Bundler::GemHelper.install_tasks
3
3
 
4
4
  require "rspec/core/rake_task"
5
5
  RSpec::Core::RakeTask.new
6
+
7
+ task :default => :spec
data/lib/ofx/account.rb CHANGED
@@ -1,10 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  class Account < Foundation
3
- attr_accessor :balance
4
- attr_accessor :bank_id
5
- attr_accessor :currency
6
- attr_accessor :id
7
- attr_accessor :transactions
8
- attr_accessor :type
5
+ attr_accessor :balance, :bank_id, :currency, :id, :transactions, :type, :available_balance
9
6
  end
10
7
  end
data/lib/ofx/balance.rb CHANGED
@@ -1,7 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  class Balance < Foundation
3
- attr_accessor :amount
4
- attr_accessor :amount_in_pennies
5
- attr_accessor :posted_at
5
+ attr_accessor :amount, :amount_in_pennies, :posted_at
6
6
  end
7
- end
7
+ end
8
+
data/lib/ofx/errors.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  class UnsupportedFileError < StandardError; end
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  class Foundation
3
5
  def initialize(attrs)
@@ -6,4 +8,5 @@ module OFX
6
8
  end
7
9
  end
8
10
  end
9
- end
11
+ end
12
+
@@ -1,20 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  module Parser
3
5
  class OFX102
4
- VERSION = "1.0.2"
6
+ VERSION = '1.0.2'
5
7
 
6
8
  ACCOUNT_TYPES = {
7
- "CHECKING" => :checking
8
- }
9
-
10
- TRANSACTION_TYPES = [
11
- 'ATM', 'CASH', 'CHECK', 'CREDIT', 'DEBIT', 'DEP', 'DIRECTDEBIT', 'DIRECTDEP', 'DIV',
12
- 'FEE', 'INT', 'OTHER', 'PAYMENT', 'POS', 'REPEATPMT', 'SRVCHG', 'XFER'
13
- ].inject({}) { |hash, tran_type| hash[tran_type] = tran_type.downcase.to_sym; hash }
14
-
15
- attr_reader :headers
16
- attr_reader :body
17
- attr_reader :html
9
+ 'CHECKING' => :checking,
10
+ 'SAVINGS' => :savings,
11
+ 'CREDITLINE' => :creditline,
12
+ 'MONEYMRKT' => :moneymrkt
13
+ }.freeze
14
+
15
+ TRANSACTION_TYPES = %w[
16
+ ATM CASH CHECK CREDIT DEBIT DEP DIRECTDEBIT DIRECTDEP DIV
17
+ FEE INT OTHER PAYMENT POS REPEATPMT SRVCHG XFER
18
+ ].each_with_object({}) do |tran_type, hash|
19
+ hash[tran_type] = tran_type.downcase.to_sym
20
+ end
21
+
22
+ SEVERITY = {
23
+ 'INFO' => :info,
24
+ 'WARN' => :warn,
25
+ 'ERROR' => :error
26
+ }.freeze
27
+
28
+ attr_reader :headers, :body, :html
18
29
 
19
30
  def initialize(options = {})
20
31
  @headers = options[:headers]
@@ -22,86 +33,171 @@ module OFX
22
33
  @html = Nokogiri::HTML.parse(body)
23
34
  end
24
35
 
36
+ def statements
37
+ @statements ||= html.search('stmttrnrs, ccstmttrnrs').collect { |node| build_statement(node) }
38
+ end
39
+
40
+ def accounts
41
+ @accounts ||= html.search('stmttrnrs, ccstmttrnrs').collect { |node| build_account(node) }
42
+ end
43
+
44
+ # DEPRECATED: kept for legacy support
25
45
  def account
26
- @account ||= build_account
46
+ @account ||= build_account(html.search('stmttrnrs, ccstmttrnrs').first)
47
+ end
48
+
49
+ def sign_on
50
+ @sign_on ||= build_sign_on
27
51
  end
28
52
 
29
53
  def self.parse_headers(header_text)
30
54
  # Change single CR's to LF's to avoid issues with some banks
31
- header_text.gsub!(/\r(?!\n)/, "\n")
55
+ header_text.gsub!(/\r(?!\n)/, '\n')
32
56
 
33
57
  # Parse headers. When value is NONE, convert it to nil.
34
- headers = header_text.to_enum(:each_line).inject({}) do |memo, line|
58
+ headers = header_text.to_enum(:each_line).each_with_object({}) do |line, memo|
35
59
  _, key, value = *line.match(/^(.*?):(.*?)\s*(\r?\n)*$/)
36
60
 
37
61
  unless key.nil?
38
- memo[key] = value == "NONE" ? nil : value
62
+ memo[key] = value == 'NONE' ? nil : value
39
63
  end
40
-
41
- memo
42
64
  end
43
65
 
44
66
  return headers unless headers.empty?
45
67
  end
46
68
 
47
69
  private
48
- def build_account
70
+
71
+ def build_statement(node)
72
+ stmrs_node = node.search('stmtrs, ccstmtrs')
73
+ account = build_account(node)
74
+ OFX::Statement.new(
75
+ currency: stmrs_node.search('curdef').inner_text,
76
+ start_date: build_date(stmrs_node.search('banktranlist > dtstart').inner_text),
77
+ end_date: build_date(stmrs_node.search('banktranlist > dtend').inner_text),
78
+ account: account,
79
+ transactions: account.transactions,
80
+ balance: account.balance,
81
+ available_balance: account.available_balance
82
+ )
83
+ end
84
+
85
+ def build_account(node)
49
86
  OFX::Account.new({
50
- :bank_id => html.search("bankacctfrom > bankid").inner_text,
51
- :id => html.search("bankacctfrom > acctid").inner_text,
52
- :type => ACCOUNT_TYPES[html.search("bankacctfrom > accttype").inner_text.to_s.upcase],
53
- :transactions => build_transactions,
54
- :balance => build_balance,
55
- :currency => html.search("bankmsgsrsv1 > stmttrnrs > stmtrs > curdef").inner_text
56
- })
87
+ bank_id: node.search('bankacctfrom > bankid').inner_text,
88
+ id: node.search('bankacctfrom > acctid, ccacctfrom > acctid').inner_text,
89
+ type: ACCOUNT_TYPES[node.search('bankacctfrom > accttype').inner_text.to_s.upcase],
90
+ transactions: build_transactions(node),
91
+ balance: build_balance(node),
92
+ available_balance: build_available_balance(node),
93
+ currency: node.search('stmtrs > curdef, ccstmtrs > curdef').inner_text
94
+ })
57
95
  end
58
96
 
59
- def build_transactions
60
- html.search("banktranlist > stmttrn").collect do |element|
97
+ def build_status(node)
98
+ OFX::Status.new({
99
+ code: node.search('code').inner_text.to_i,
100
+ severity: SEVERITY[node.search('severity').inner_text],
101
+ message: node.search('message').inner_text
102
+ })
103
+ end
104
+
105
+ def build_sign_on
106
+ OFX::SignOn.new({
107
+ language: html.search('signonmsgsrsv1 > sonrs > language').inner_text,
108
+ fi_id: html.search('signonmsgsrsv1 > sonrs > fi > fid').inner_text,
109
+ fi_name: html.search('signonmsgsrsv1 > sonrs > fi > org').inner_text,
110
+ status: build_status(html.search('signonmsgsrsv1 > sonrs > status'))
111
+ })
112
+ end
113
+
114
+ def build_transactions(node)
115
+ node.search('banktranlist > stmttrn').collect do |element|
61
116
  build_transaction(element)
62
117
  end
63
118
  end
64
119
 
65
120
  def build_transaction(element)
121
+ occurred_at = begin
122
+ build_date(element.search('dtuser').inner_text)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
66
127
  OFX::Transaction.new({
67
- :amount => build_amount(element),
68
- :amount_in_pennies => (build_amount(element) * 100).to_i,
69
- :fit_id => element.search("fitid").inner_text,
70
- :memo => element.search("memo").inner_text,
71
- :name => element.search("name").inner_text,
72
- :payee => element.search("payee").inner_text,
73
- :check_number => element.search("checknum").inner_text,
74
- :ref_number => element.search("refnum").inner_text,
75
- :posted_at => build_date(element.search("dtposted").inner_text),
76
- :type => build_type(element)
77
- })
128
+ amount: build_amount(element),
129
+ amount_in_pennies: (build_amount(element) * 100).to_i,
130
+ fit_id: element.search('fitid').inner_text,
131
+ memo: element.search('memo').inner_text,
132
+ name: element.search('name').inner_text,
133
+ payee: element.search('payee').inner_text,
134
+ check_number: element.search('checknum').inner_text,
135
+ ref_number: element.search('refnum').inner_text,
136
+ posted_at: build_date(element.search('dtposted').inner_text),
137
+ occurred_at: occurred_at,
138
+ type: build_type(element),
139
+ sic: element.search('sic').inner_text
140
+ })
78
141
  end
79
142
 
80
143
  def build_type(element)
81
- TRANSACTION_TYPES[element.search("trntype").inner_text.to_s.upcase]
144
+ TRANSACTION_TYPES[element.search('trntype').inner_text.to_s.upcase]
82
145
  end
83
146
 
84
147
  def build_amount(element)
85
- BigDecimal.new(element.search("trnamt").inner_text)
148
+ to_decimal(element.search('trnamt').inner_text)
86
149
  end
87
150
 
151
+ # Input format is `YYYYMMDDHHMMSS.XXX[gmt offset[:tz name]]`
88
152
  def build_date(date)
89
- _, year, month, day, hour, minutes, seconds = *date.match(/(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?/)
153
+ tz_pattern = /(?:\[([+-]?\d{1,4}):\S{3}\])?\z/
154
+
155
+ # Timezone offset handling
156
+ date.sub!(tz_pattern, '')
157
+ offset = Regexp.last_match(1)
158
+
159
+ if offset
160
+ # Offset padding
161
+ _, hours, mins = *offset.match(/\A([+-]?\d{1,2})(\d{0,2})?\z/)
162
+ offset = format('%+03d%02d', hours.to_i, mins.to_i)
163
+ else
164
+ offset = '+0000'
165
+ end
90
166
 
91
- date = "#{year}-#{month}-#{day} "
92
- date << "#{hour}:#{minutes}:#{seconds}" if hour && minutes && seconds
167
+ date << " #{offset}"
93
168
 
94
169
  Time.parse(date)
95
170
  end
96
171
 
97
- def build_balance
98
- amount = html.search("ledgerbal > balamt").inner_text.to_f
172
+ def build_balance(node)
173
+ amount = to_decimal(node.search('ledgerbal > balamt').inner_text)
174
+ posted_at = begin
175
+ build_date(node.search('ledgerbal > dtasof').inner_text)
176
+ rescue StandardError
177
+ nil
178
+ end
99
179
 
100
180
  OFX::Balance.new({
101
- :amount => amount,
102
- :amount_in_pennies => (amount * 100).to_i,
103
- :posted_at => build_date(html.search("ledgerbal > dtasof").inner_text)
104
- })
181
+ amount: ammount,
182
+ amount_in_pennies: (amount * 100).to_i,
183
+ posted_at: posted_at
184
+ })
185
+ end
186
+
187
+ def build_available_balance(node)
188
+ if node.search('availbal').size > 0
189
+ amount = to_decimal(node.search('availbal > balamt').inner_text)
190
+
191
+ OFX::Balance.new({
192
+ amount: amount,
193
+ amount_in_pennies: (amount * 100).to_i,
194
+ posted_at: build_date(node.search('availbal > dtasof').inner_text)
195
+ })
196
+ end
197
+ end
198
+
199
+ def to_decimal(amount)
200
+ BigDecimal(amount.to_s.gsub(',', '.'))
105
201
  end
106
202
  end
107
203
  end
@@ -0,0 +1,7 @@
1
+ module OFX
2
+ module Parser
3
+ class OFX103 < OFX102
4
+ VERSION = '1.0.3'
5
+ end
6
+ end
7
+ end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  module Parser
3
5
  class OFX211 < OFX102
4
- VERSION = "2.1.1"
6
+ VERSION = '2.1.1'
5
7
 
6
8
  def self.parse_headers(header_text)
7
9
  doc = Nokogiri::XML(header_text)
@@ -9,7 +11,7 @@ module OFX
9
11
  # Nokogiri can't search for processing instructions, so we
10
12
  # need to do this manually.
11
13
  doc.children.each do |e|
12
- if e.type == Nokogiri::XML::Node::PI_NODE && e.name == "OFX"
14
+ if e.type == Nokogiri::XML::Node::PI_NODE && e.name == 'OFX'
13
15
  # Getting the attributes from the element doesn't seem to
14
16
  # work either.
15
17
  return extract_headers(e.text)
@@ -19,13 +21,14 @@ module OFX
19
21
  nil
20
22
  end
21
23
 
22
- private
23
24
  def self.extract_headers(text)
24
25
  headers = {}
25
26
  text.split(/\s+/).each do |attr_text|
26
27
  match = /(.+)="(.+)"/.match(attr_text)
27
28
  next unless match
28
- k, v = match[1], match[2]
29
+
30
+ k = match[1]
31
+ v = match[2]
29
32
  headers[k] = v
30
33
  end
31
34
  headers
@@ -33,6 +36,7 @@ module OFX
33
36
 
34
37
  def self.strip_quotes(s)
35
38
  return unless s
39
+
36
40
  s.sub(/^"(.*)"$/, '\1')
37
41
  end
38
42
  end
data/lib/ofx/parser.rb CHANGED
@@ -1,27 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  module Parser
3
5
  class Base
4
- attr_reader :headers
5
- attr_reader :body
6
- attr_reader :content
7
- attr_reader :parser
6
+ attr_reader :headers, :body, :conten, :parser
8
7
 
9
8
  def initialize(resource)
10
9
  resource = open_resource(resource)
11
10
  resource.rewind
12
- @content = convert_to_utf8(resource.read)
13
-
14
11
  begin
12
+ @content = convert_to_utf8(resource.read)
15
13
  @headers, @body = prepare(content)
16
- rescue Exception
14
+ rescue StandardError
17
15
  raise OFX::UnsupportedFileError
18
16
  end
19
17
 
20
- case headers["VERSION"]
21
- when /102/ then
22
- @parser = OFX102.new(:headers => headers, :body => body)
23
- when /200|211/ then
24
- @parser = OFX211.new(:headers => headers, :body => body)
18
+ case headers['VERSION']
19
+ when /102/
20
+ @parser = OFX102.new(headers: headers, body: body)
21
+ when /103/
22
+ @parser = OFX103.new(headersu: headers, body: body)
23
+ when /200|202|211|220/
24
+ @parser = OFX211.new(headers: headers, body: body)
25
25
  else
26
26
  raise OFX::UnsupportedFileError
27
27
  end
@@ -33,11 +33,12 @@ module OFX
33
33
  else
34
34
  open(resource)
35
35
  end
36
- rescue Exception
36
+ rescue StandardError
37
37
  StringIO.new(resource)
38
38
  end
39
39
 
40
40
  private
41
+
41
42
  def prepare(content)
42
43
  # split headers & body
43
44
  header_text, body = content.dup.split(/<OFX>/, 2)
@@ -54,9 +55,9 @@ module OFX
54
55
  end
55
56
 
56
57
  # Replace body tags to parse it with Nokogiri
57
- body.gsub!(/>\s+</m, "><")
58
- body.gsub!(/\s+</m, "<")
59
- body.gsub!(/>\s+/m, ">")
58
+ body.gsub!(/>\s+</m, '><')
59
+ body.gsub!(/\s+</m, '<')
60
+ body.gsub!(/>\s+/m, '>')
60
61
  body.gsub!(/<(\w+?)>([^<]+)/m, '<\1>\2</\1>')
61
62
 
62
63
  [headers, body]
@@ -64,7 +65,8 @@ module OFX
64
65
 
65
66
  def convert_to_utf8(string)
66
67
  return string if Kconv.isutf8(string)
67
- Iconv.conv("UTF-8", "LATIN1//IGNORE", string)
68
+
69
+ string.encode('UTF-8', 'ISO-8859-1')
68
70
  end
69
71
  end
70
72
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ class SignOn < Foundation
5
+ attr_accessor :language, :fi_id, :fi_name, :status
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ class Statement < Foundation
5
+ attr_accessor :account, :available_balance, :balance, :currency, :start_date, :end_date, :transactions
6
+ end
7
+ end
data/lib/ofx/status.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ # Error Reporting Aggregate
5
+ class Status < Foundation
6
+ attr_accessor :code, :severity, :message
7
+
8
+ def success?
9
+ code.zero?
10
+ end
11
+ end
12
+ end
@@ -1,14 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  class Transaction < Foundation
3
- attr_accessor :amount
4
- attr_accessor :amount_in_pennies
5
- attr_accessor :check_number
6
- attr_accessor :fit_id
7
- attr_accessor :memo
8
- attr_accessor :name
9
- attr_accessor :payee
10
- attr_accessor :posted_at
11
- attr_accessor :ref_number
12
- attr_accessor :type
5
+ attr_accessor :amount, :amount_in_pennies, :check_number, :fit_id, :memo, :name, :payee, :posted_at, :occurred_at,
6
+ :ref_number, :type, :sic
13
7
  end
14
8
  end
data/lib/ofx/version.rb CHANGED
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OFX
2
4
  module Version
3
5
  MAJOR = 0
4
6
  MINOR = 3
5
- PATCH = 1
7
+ PATCH = 5
6
8
  STRING = "#{MAJOR}.#{MINOR}.#{PATCH}"
7
9
  end
8
10
  end
data/lib/ofx.rb CHANGED
@@ -1,19 +1,24 @@
1
- require "open-uri"
2
- require "nokogiri"
3
- require "bigdecimal"
1
+ # frozen_string_literal: true
4
2
 
5
- require "iconv"
6
- require "kconv"
3
+ require 'open-uri'
4
+ require 'nokogiri'
5
+ require 'bigdecimal'
7
6
 
8
- require "ofx/errors"
9
- require "ofx/parser"
10
- require "ofx/parser/ofx102"
11
- require "ofx/parser/ofx211"
12
- require "ofx/foundation"
13
- require "ofx/balance"
14
- require "ofx/account"
15
- require "ofx/transaction"
16
- require "ofx/version"
7
+ require 'kconv'
8
+
9
+ require 'ofx/errors'
10
+ require 'ofx/parser'
11
+ require 'ofx/parser/ofx102'
12
+ require 'ofx/parser/ofx103'
13
+ require 'ofx/parser/ofx211'
14
+ require 'ofx/foundation'
15
+ require 'ofx/balance'
16
+ require 'ofx/account'
17
+ require 'ofx/sign_on'
18
+ require 'ofx/status'
19
+ require 'ofx/statement'
20
+ require 'ofx/transaction'
21
+ require 'ofx/version'
17
22
 
18
23
  def OFX(resource, &block)
19
24
  parser = OFX::Parser::Base.new(resource).parser