ofx 0.2.9 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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