ofx 0.2.9 → 0.3.4

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: f010daf75c910a87b19c737c27a0970a4a410f5e5b6ba5916f4546e2bb5f9abb
4
+ data.tar.gz: abf6b0090855e5c063ce2d627e8f2b4907be64a71289cb520d4ae4bf7687371e
5
+ SHA512:
6
+ metadata.gz: f3ccb6719848c32e3047243715a3ec2aabbc80b2f241bfd2ff7dcbda819bf1baeb3086ee751459a28643345d4272bf4b4481dd56ba727f9ebf94ea6a680d4cf0
7
+ data.tar.gz: 48076b4428d20fa368544099691ebe1bf877b724b49cfce83265545a84b62b07e45759e00ae0febaf254090d53c157a1f9b7b6340f5c9d59237aa1847e590ca5
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
@@ -6,5 +6,6 @@ module OFX
6
6
  attr_accessor :id
7
7
  attr_accessor :transactions
8
8
  attr_accessor :type
9
+ attr_accessor :available_balance
9
10
  end
10
11
  end
@@ -1,27 +1,31 @@
1
- require "bigdecimal"
1
+ # frozen_string_literal: true
2
2
 
3
3
  module OFX
4
4
  module Parser
5
5
  class OFX102
6
- VERSION = "1.0.2"
6
+ VERSION = '1.0.2'
7
7
 
8
8
  ACCOUNT_TYPES = {
9
- "CHECKING" => :checking
10
- }
11
-
12
- TRANSACTION_TYPES = {
13
- "CREDIT" => :credit,
14
- "DEBIT" => :debit,
15
- "OTHER" => :other,
16
- "DEP" => :dep,
17
- "XFER" => :xfer,
18
- "CASH" => :cash,
19
- "CHECK" => :check
20
- }
21
-
22
- attr_reader :headers
23
- attr_reader :body
24
- 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
+ }.freeza
27
+
28
+ attr_reader :headers, :body, :html
25
29
 
26
30
  def initialize(options = {})
27
31
  @headers = options[:headers]
@@ -29,67 +33,171 @@ module OFX
29
33
  @html = Nokogiri::HTML.parse(body)
30
34
  end
31
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
32
45
  def account
33
- @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
51
+ end
52
+
53
+ def self.parse_headers(header_text)
54
+ # Change single CR's to LF's to avoid issues with some banks
55
+ header_text.gsub!(/\r(?!\n)/, '\n')
56
+
57
+ # Parse headers. When value is NONE, convert it to nil.
58
+ headers = header_text.to_enum(:each_line).each_with_object({}) do |line, memo|
59
+ _, key, value = *line.match(/^(.*?):(.*?)\s*(\r?\n)*$/)
60
+
61
+ unless key.nil?
62
+ memo[key] = value == 'NONE' ? nil : value
63
+ end
64
+ end
65
+
66
+ return headers unless headers.empty?
34
67
  end
35
68
 
36
69
  private
37
- 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)
38
86
  OFX::Account.new({
39
- :bank_id => html.search("bankacctfrom > bankid").inner_text,
40
- :id => html.search("bankacctfrom > acctid").inner_text,
41
- :type => ACCOUNT_TYPES[html.search("bankacctfrom > accttype").inner_text],
42
- :transactions => build_transactions,
43
- :balance => build_balance,
44
- :currency => html.search("bankmsgsrsv1 > stmttrnrs > stmtrs > curdef").inner_text
45
- })
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
+ })
95
+ end
96
+
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
+ })
46
103
  end
47
104
 
48
- def build_transactions
49
- html.search("banktranlist > stmttrn").collect do |element|
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|
50
116
  build_transaction(element)
51
117
  end
52
118
  end
53
119
 
54
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
+
55
127
  OFX::Transaction.new({
56
- :amount => build_amount(element),
57
- :amount_in_pennies => (build_amount(element) * 100).to_i,
58
- :fit_id => element.search("fitid").inner_text,
59
- :memo => element.search("memo").inner_text,
60
- :payee => element.search("payee").inner_text,
61
- :check_number => element.search("checknum").inner_text,
62
- :ref_number => element.search("refnum").inner_text,
63
- :posted_at => build_date(element.search("dtposted").inner_text),
64
- :type => build_type(element)
65
- })
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:,
138
+ type: build_type(element),
139
+ sic: element.search('sic').inner_text
140
+ })
66
141
  end
67
142
 
68
143
  def build_type(element)
69
- TRANSACTION_TYPES[element.search("trntype").inner_text]
144
+ TRANSACTION_TYPES[element.search('trntype').inner_text.to_s.upcase]
70
145
  end
71
146
 
72
147
  def build_amount(element)
73
- BigDecimal.new(element.search("trnamt").inner_text)
148
+ to_decimal(element.search('trnamt').inner_text)
74
149
  end
75
150
 
151
+ # Input format is `YYYYMMDDHHMMSS.XXX[gmt offset[:tz name]]`
76
152
  def build_date(date)
77
- _, 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
78
166
 
79
- date = "#{year}-#{month}-#{day} "
80
- date << "#{hour}:#{minutes}:#{seconds}" if hour && minutes && seconds
167
+ date << ' #{offset}'
81
168
 
82
169
  Time.parse(date)
83
170
  end
84
171
 
85
- def build_balance
86
- 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
87
179
 
88
180
  OFX::Balance.new({
89
- :amount => amount,
90
- :amount_in_pennies => (amount * 100).to_i,
91
- :posted_at => build_date(html.search("ledgerbal > dtasof").inner_text)
92
- })
181
+ amount:,
182
+ amount_in_pennies: (amount * 100).to_i,
183
+ 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:,
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(',', '.'))
93
201
  end
94
202
  end
95
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
@@ -0,0 +1,40 @@
1
+ module OFX
2
+ module Parser
3
+ class OFX211 < OFX102
4
+ VERSION = "2.1.1"
5
+
6
+ def self.parse_headers(header_text)
7
+ doc = Nokogiri::XML(header_text)
8
+
9
+ # Nokogiri can't search for processing instructions, so we
10
+ # need to do this manually.
11
+ doc.children.each do |e|
12
+ if e.type == Nokogiri::XML::Node::PI_NODE && e.name == "OFX"
13
+ # Getting the attributes from the element doesn't seem to
14
+ # work either.
15
+ return extract_headers(e.text)
16
+ end
17
+ end
18
+
19
+ nil
20
+ end
21
+
22
+ private
23
+ def self.extract_headers(text)
24
+ headers = {}
25
+ text.split(/\s+/).each do |attr_text|
26
+ match = /(.+)="(.+)"/.match(attr_text)
27
+ next unless match
28
+ k, v = match[1], match[2]
29
+ headers[k] = v
30
+ end
31
+ headers
32
+ end
33
+
34
+ def self.strip_quotes(s)
35
+ return unless s
36
+ s.sub(/^"(.*)"$/, '\1')
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/ofx/parser.rb CHANGED
@@ -7,20 +7,22 @@ module OFX
7
7
  attr_reader :parser
8
8
 
9
9
  def initialize(resource)
10
- resource = open_resource(resource)
11
- resource.rewind
12
- @content = convert_to_utf8(resource.read)
13
-
10
+ resource = open_resource(resource)
11
+ resource.rewind
14
12
  begin
13
+ @content = convert_to_utf8(resource.read)
15
14
  @headers, @body = prepare(content)
16
- rescue Exception
15
+ rescue
17
16
  raise OFX::UnsupportedFileError
18
17
  end
19
18
 
20
-
21
- case @headers["VERSION"]
22
- when "102" then
23
- @parser = OFX::Parser::OFX102.new(:headers => headers, :body => body)
19
+ case headers["VERSION"]
20
+ when /102/ then
21
+ @parser = OFX102.new(:headers => headers, :body => body)
22
+ when /103/ then
23
+ @parser = OFX103.new(:headers => headers, :body => body)
24
+ when /200|202|211|220/ then
25
+ @parser = OFX211.new(:headers => headers, :body => body)
24
26
  else
25
27
  raise OFX::UnsupportedFileError
26
28
  end
@@ -28,37 +30,34 @@ module OFX
28
30
 
29
31
  def open_resource(resource)
30
32
  if resource.respond_to?(:read)
31
- return resource
33
+ resource
32
34
  else
33
- begin
34
- return open(resource)
35
- rescue
36
- return StringIO.new(resource)
37
- end
35
+ open(resource)
38
36
  end
37
+ rescue
38
+ StringIO.new(resource)
39
39
  end
40
40
 
41
41
  private
42
42
  def prepare(content)
43
- # Split headers & body
44
- headers, body = content.dup.split(/<OFX>/, 2)
45
-
46
- # Change single CR's to LF's to avoid issues with some banks
47
- headers.gsub!(/\r(?!\n)/, "\n")
43
+ # split headers & body
44
+ header_text, body = content.dup.split(/<OFX>/, 2)
48
45
 
49
46
  raise OFX::UnsupportedFileError unless body
50
47
 
51
- # Parse headers. When value is NONE, convert it to nil.
52
- headers = headers.to_enum(:each_line).inject({}) do |memo, line|
53
- _, key, value = *line.match(/^(.*?):(.*?)(\r?\n)*$/)
54
- memo[key] = value == "NONE" ? nil : value
55
- memo
48
+ # Header format is different between versions. Give each
49
+ # parser a chance to parse the headers.
50
+ headers = nil
51
+
52
+ OFX::Parser.constants.grep(/OFX/).each do |name|
53
+ headers = OFX::Parser.const_get(name).parse_headers(header_text)
54
+ break if headers
56
55
  end
57
56
 
58
57
  # Replace body tags to parse it with Nokogiri
59
- body.gsub!(/>\s+</m, '><')
60
- body.gsub!(/\s+</m, '<')
61
- body.gsub!(/>\s+/m, '>')
58
+ body.gsub!(/>\s+</m, "><")
59
+ body.gsub!(/\s+</m, "<")
60
+ body.gsub!(/>\s+/m, ">")
62
61
  body.gsub!(/<(\w+?)>([^<]+)/m, '<\1>\2</\1>')
63
62
 
64
63
  [headers, body]
@@ -66,8 +65,8 @@ module OFX
66
65
 
67
66
  def convert_to_utf8(string)
68
67
  return string if Kconv.isutf8(string)
69
- Iconv.conv('UTF-8', 'LATIN1//IGNORE', string)
68
+ string.encode("UTF-8", "ISO-8859-1")
70
69
  end
71
70
  end
72
71
  end
73
- end
72
+ end
@@ -0,0 +1,8 @@
1
+ module OFX
2
+ class SignOn < Foundation
3
+ attr_accessor :language
4
+ attr_accessor :fi_id
5
+ attr_accessor :fi_name
6
+ attr_accessor :status
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module OFX
2
+ class Statement < Foundation
3
+ attr_accessor :account
4
+ attr_accessor :available_balance
5
+ attr_accessor :balance
6
+ attr_accessor :currency
7
+ attr_accessor :start_date
8
+ attr_accessor :end_date
9
+ attr_accessor :transactions
10
+ end
11
+ end
data/lib/ofx/status.rb ADDED
@@ -0,0 +1,12 @@
1
+ module OFX
2
+ # Error Reporting Aggregate
3
+ class Status < Foundation
4
+ attr_accessor :code # Error code
5
+ attr_accessor :severity # Severity of the error
6
+ attr_accessor :message # Textual explanation
7
+
8
+ def success?
9
+ code == 0
10
+ end
11
+ end
12
+ end
@@ -5,9 +5,12 @@ module OFX
5
5
  attr_accessor :check_number
6
6
  attr_accessor :fit_id
7
7
  attr_accessor :memo
8
+ attr_accessor :name
8
9
  attr_accessor :payee
9
10
  attr_accessor :posted_at
11
+ attr_accessor :occurred_at
10
12
  attr_accessor :ref_number
11
13
  attr_accessor :type
14
+ attr_accessor :sic
12
15
  end
13
- end
16
+ end
data/lib/ofx/version.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  module OFX
2
2
  module Version
3
3
  MAJOR = 0
4
- MINOR = 2
5
- PATCH = 9
4
+ MINOR = 3
5
+ PATCH = 4
6
6
  STRING = "#{MAJOR}.#{MINOR}.#{PATCH}"
7
7
  end
8
8
  end
data/lib/ofx.rb CHANGED
@@ -1,17 +1,24 @@
1
- require "open-uri"
2
- require "nokogiri"
1
+ # frozen_string_literal: true
3
2
 
4
- require "iconv"
5
- require "kconv"
3
+ require 'open-uri'
4
+ require 'nokogiri'
5
+ require 'bigdecimal'
6
6
 
7
- require "ofx/errors"
8
- require "ofx/parser"
9
- require "ofx/parser/ofx102"
10
- require "ofx/foundation"
11
- require "ofx/balance"
12
- require "ofx/account"
13
- require "ofx/transaction"
14
- 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'
15
22
 
16
23
  def OFX(resource, &block)
17
24
  parser = OFX::Parser::Base.new(resource).parser