salt-parser 0.0.1

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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +1 -0
  4. data/Gemfile +15 -0
  5. data/Gemfile.lock +88 -0
  6. data/README.rdoc +39 -0
  7. data/Rakefile +7 -0
  8. data/lib/ofx/account.rb +28 -0
  9. data/lib/ofx/accounts.rb +9 -0
  10. data/lib/ofx/balance.rb +15 -0
  11. data/lib/ofx/builder.rb +61 -0
  12. data/lib/ofx/dependencies.rb +13 -0
  13. data/lib/ofx/parser/base.rb +366 -0
  14. data/lib/ofx/parser/ofx102.rb +28 -0
  15. data/lib/ofx/parser/ofx211.rb +21 -0
  16. data/lib/ofx/sign_on.rb +18 -0
  17. data/lib/ofx/transaction.rb +28 -0
  18. data/lib/qif/account.rb +19 -0
  19. data/lib/qif/accounts.rb +54 -0
  20. data/lib/qif/builder.rb +37 -0
  21. data/lib/qif/dependencies.rb +8 -0
  22. data/lib/qif/parser.rb +68 -0
  23. data/lib/qif/transaction.rb +28 -0
  24. data/lib/salt-parser/accounts.rb +11 -0
  25. data/lib/salt-parser/base.rb +19 -0
  26. data/lib/salt-parser/builder.rb +24 -0
  27. data/lib/salt-parser/errors.rb +14 -0
  28. data/lib/salt-parser/version.rb +8 -0
  29. data/salt-parser.gemspec +24 -0
  30. data/spec/ofx/account_spec.rb +97 -0
  31. data/spec/ofx/accounts_response_spec.rb +45 -0
  32. data/spec/ofx/accounts_spec.rb +34 -0
  33. data/spec/ofx/balance_spec.rb +32 -0
  34. data/spec/ofx/builder_spec.rb +136 -0
  35. data/spec/ofx/error_request_spec.rb +37 -0
  36. data/spec/ofx/fixtures/accounts_partial.ofx +52 -0
  37. data/spec/ofx/fixtures/accounts_request.ofx +11 -0
  38. data/spec/ofx/fixtures/accounts_response.ofx +109 -0
  39. data/spec/ofx/fixtures/avatar.gif +0 -0
  40. data/spec/ofx/fixtures/bb.ofx +700 -0
  41. data/spec/ofx/fixtures/credit_card_response.ofx +52 -0
  42. data/spec/ofx/fixtures/creditcard.ofx +79 -0
  43. data/spec/ofx/fixtures/creditcard_transactions_request.ofx +11 -0
  44. data/spec/ofx/fixtures/creditcards_partial.ofx +85 -0
  45. data/spec/ofx/fixtures/date_missing.ofx +73 -0
  46. data/spec/ofx/fixtures/empty_balance.ofx +44 -0
  47. data/spec/ofx/fixtures/invalid_version.ofx +308 -0
  48. data/spec/ofx/fixtures/investment_transactions_response.ofx +108 -0
  49. data/spec/ofx/fixtures/investment_transactions_response2.ofx +200 -0
  50. data/spec/ofx/fixtures/investments_with_mkval.ofx +99 -0
  51. data/spec/ofx/fixtures/missing_headers.ofx +47 -0
  52. data/spec/ofx/fixtures/mixed_accountinfo_response.ofx +58 -0
  53. data/spec/ofx/fixtures/ms_money.ofx +52 -0
  54. data/spec/ofx/fixtures/request_error.ofx +39 -0
  55. data/spec/ofx/fixtures/request_error2.ofx +39 -0
  56. data/spec/ofx/fixtures/request_error3.ofx +36 -0
  57. data/spec/ofx/fixtures/sample_examples/sample_401K_loan.qfx +651 -0
  58. data/spec/ofx/fixtures/sample_examples/sample_banking.qbo +258 -0
  59. data/spec/ofx/fixtures/sample_examples/sample_banking.qfx +258 -0
  60. data/spec/ofx/fixtures/sample_examples/sample_banking_multiacct.qfx +284 -0
  61. data/spec/ofx/fixtures/sample_examples/sample_credit_card.qfx +257 -0
  62. data/spec/ofx/fixtures/sample_examples/sample_investment.qfx +654 -0
  63. data/spec/ofx/fixtures/transactions_empty.ofx +60 -0
  64. data/spec/ofx/fixtures/utf8.ofx +65 -0
  65. data/spec/ofx/fixtures/v102.ofx +314 -0
  66. data/spec/ofx/fixtures/v202.ofx +22 -0
  67. data/spec/ofx/fixtures/v211.ofx +85 -0
  68. data/spec/ofx/investment_accounts_spec.rb +70 -0
  69. data/spec/ofx/ofx102_spec.rb +44 -0
  70. data/spec/ofx/ofx211_spec.rb +68 -0
  71. data/spec/ofx/ofx_parser_spec.rb +100 -0
  72. data/spec/ofx/sign_on_spec.rb +49 -0
  73. data/spec/ofx/transaction_spec.rb +157 -0
  74. data/spec/qif/account_spec.rb +42 -0
  75. data/spec/qif/fixtures/3_records_ddmmyy.qif +19 -0
  76. data/spec/qif/fixtures/3_records_ddmmyyyy.qif +19 -0
  77. data/spec/qif/fixtures/3_records_dmyy.qif +19 -0
  78. data/spec/qif/fixtures/3_records_invalid_header.qif +20 -0
  79. data/spec/qif/fixtures/3_records_mdyy.qif +19 -0
  80. data/spec/qif/fixtures/3_records_mmddyy.qif +19 -0
  81. data/spec/qif/fixtures/3_records_mmddyyyy.qif +19 -0
  82. data/spec/qif/fixtures/3_records_spaced.qif +19 -0
  83. data/spec/qif/fixtures/bank_account.qif +19 -0
  84. data/spec/qif/fixtures/empty_body.qif +1 -0
  85. data/spec/qif/fixtures/empty_header.qif +18 -0
  86. data/spec/qif/fixtures/incompatible_date_formats.qif +13 -0
  87. data/spec/qif/fixtures/invalid_date_format.qif +7 -0
  88. data/spec/qif/fixtures/not_a_QIF_file.txt +3 -0
  89. data/spec/qif/fixtures/quicken_non_investement_account.qif +30 -0
  90. data/spec/qif/fixtures/unknown_account.qif +20 -0
  91. data/spec/qif/fixtures/various_date_format.qif +19 -0
  92. data/spec/qif/fixtures/with_categories_list.qif +8669 -0
  93. data/spec/qif/parser_spec.rb +156 -0
  94. data/spec/qif/transaction_spec.rb +18 -0
  95. data/spec/spec_helper.rb +37 -0
  96. data/spec/support/fixture.rb +9 -0
  97. metadata +208 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ba5f5e0a409feb645962c2649710323a47534b8c
4
+ data.tar.gz: 43f111a2394f816e7b6905931eef337a95dd8875
5
+ SHA512:
6
+ metadata.gz: 595e2860944ab5083e02b8146eaf6d1be427ad3a061f28e3ad2350eb858eb2b9fcc4dd22732888a99adadf4d5f39a8d38481b50d0fd18b419eb184e299907983
7
+ data.tar.gz: f909cf1b0a524d9fe9904b77c6f78baef50f3f13fb81d6cba3fe044ca5c782778be7598055acb2199d026a8f1285351801ad3067b955cf568a4a8584e3498860
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ .bundle/*
2
+ coverage/*
3
+ vendor/bundle
4
+ .DS_Store
5
+ *.gem
6
+ pkg
7
+ .project
8
+
9
+ # temporary files
10
+ bin/**
11
+ spec/tmp/**
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source "https://rubygems.org"
2
+
3
+ group :test do
4
+ gem 'timecop'
5
+ gem 'rspec'
6
+ gem 'simplecov', require: false
7
+ end
8
+
9
+ gem 'activesupport'
10
+ gem 'chronic'
11
+ gem 'rest-client'
12
+ gem 'rack'
13
+ gem 'yajl-ruby', require: 'yajl'
14
+ gem 'nokogiri'
15
+ gem 'pry-byebug', "~> 1.3.2"
data/Gemfile.lock ADDED
@@ -0,0 +1,88 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ activesupport (4.2.3)
5
+ i18n (~> 0.7)
6
+ json (~> 1.7, >= 1.7.7)
7
+ minitest (~> 5.1)
8
+ thread_safe (~> 0.3, >= 0.3.4)
9
+ tzinfo (~> 1.1)
10
+ byebug (2.7.0)
11
+ columnize (~> 0.3)
12
+ debugger-linecache (~> 1.2)
13
+ chronic (0.10.2)
14
+ coderay (1.1.0)
15
+ columnize (0.9.0)
16
+ debugger-linecache (1.2.0)
17
+ diff-lcs (1.2.5)
18
+ docile (1.1.5)
19
+ domain_name (0.5.24)
20
+ unf (>= 0.0.5, < 1.0.0)
21
+ http-cookie (1.0.2)
22
+ domain_name (~> 0.5)
23
+ i18n (0.7.0)
24
+ json (1.8.3)
25
+ method_source (0.8.2)
26
+ mime-types (2.6.1)
27
+ mini_portile (0.6.2)
28
+ minitest (5.8.0)
29
+ netrc (0.10.3)
30
+ nokogiri (1.6.6.2)
31
+ mini_portile (~> 0.6.0)
32
+ pry (0.10.1)
33
+ coderay (~> 1.1.0)
34
+ method_source (~> 0.8.1)
35
+ slop (~> 3.4)
36
+ pry-byebug (1.3.3)
37
+ byebug (~> 2.7)
38
+ pry (~> 0.10)
39
+ rack (1.6.4)
40
+ rest-client (1.8.0)
41
+ http-cookie (>= 1.0.2, < 2.0)
42
+ mime-types (>= 1.16, < 3.0)
43
+ netrc (~> 0.7)
44
+ rspec (3.3.0)
45
+ rspec-core (~> 3.3.0)
46
+ rspec-expectations (~> 3.3.0)
47
+ rspec-mocks (~> 3.3.0)
48
+ rspec-core (3.3.2)
49
+ rspec-support (~> 3.3.0)
50
+ rspec-expectations (3.3.1)
51
+ diff-lcs (>= 1.2.0, < 2.0)
52
+ rspec-support (~> 3.3.0)
53
+ rspec-mocks (3.3.2)
54
+ diff-lcs (>= 1.2.0, < 2.0)
55
+ rspec-support (~> 3.3.0)
56
+ rspec-support (3.3.0)
57
+ simplecov (0.10.0)
58
+ docile (~> 1.1.0)
59
+ json (~> 1.8)
60
+ simplecov-html (~> 0.10.0)
61
+ simplecov-html (0.10.0)
62
+ slop (3.6.0)
63
+ thread_safe (0.3.5)
64
+ timecop (0.8.0)
65
+ tzinfo (1.2.2)
66
+ thread_safe (~> 0.1)
67
+ unf (0.1.4)
68
+ unf_ext
69
+ unf_ext (0.0.7.1)
70
+ yajl-ruby (1.2.1)
71
+
72
+ PLATFORMS
73
+ ruby
74
+
75
+ DEPENDENCIES
76
+ activesupport
77
+ chronic
78
+ nokogiri
79
+ pry-byebug (~> 1.3.2)
80
+ rack
81
+ rest-client
82
+ rspec
83
+ simplecov
84
+ timecop
85
+ yajl-ruby
86
+
87
+ BUNDLED WITH
88
+ 1.10.6
data/README.rdoc ADDED
@@ -0,0 +1,39 @@
1
+ = Salt Parser
2
+
3
+ Library for parsing OFX, HBCI, QIF and SWIFT formats.
4
+
5
+ TODOS:
6
+ - Add SWIFT parser
7
+ - Add HBCI parser
8
+ - Add code examples
9
+
10
+ == Credits
11
+
12
+ Special thanks to:
13
+
14
+ - @annacruz (ofx)[https://github.com/annacruz/ofx]
15
+ - @jemmyw (qif)[https://github.com/jemmyw/Qif]
16
+
17
+
18
+ == License
19
+
20
+ (The MIT License)
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining
23
+ a copy of this software and associated documentation files (the
24
+ 'Software'), to deal in the Software without restriction, including
25
+ without limitation the rights to use, copy, modify, merge, publish,
26
+ distribute, sublicense, and/or sell copies of the Software, and to
27
+ permit persons to whom the Software is furnished to do so, subject to
28
+ the following conditions:
29
+
30
+ The above copyright notice and this permission notice shall be
31
+ included in all copies or substantial portions of the Software.
32
+
33
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
34
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
35
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
36
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
37
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
38
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
39
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler"
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require "rspec/core/rake_task"
5
+ RSpec::Core::RakeTask.new
6
+
7
+ task :default => :spec
@@ -0,0 +1,28 @@
1
+ module SaltParser
2
+ module Ofx
3
+ class Account < SaltParser::Base
4
+ attr_accessor :balance, :bank_id, :broker_id, :currency, :id, :name,
5
+ :transactions, :type, :units, :unit_price, :available_balance
6
+
7
+ def identifier
8
+ id
9
+ end
10
+
11
+ def to_hash
12
+ {
13
+ :balance => balance ? balance.to_hash : nil,
14
+ :bank_id => bank_id,
15
+ :broker_id => broker_id,
16
+ :currency => currency,
17
+ :id => id,
18
+ :name => name,
19
+ :transactions => transactions.map(&:to_hash),
20
+ :type => type,
21
+ :units => units,
22
+ :unit_price => unit_price,
23
+ :available_balance => available_balance ? available_balance.to_hash : nil
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ module SaltParser
2
+ module Ofx
3
+ class Accounts < SaltParser::Accounts
4
+ def find_by_transaction(transaction)
5
+ find(transaction.account_id)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ module SaltParser
2
+ module Ofx
3
+ class Balance < SaltParser::Base
4
+ attr_accessor :amount, :amount_in_pennies, :posted_at
5
+
6
+ def to_hash
7
+ {
8
+ :amount => amount,
9
+ :amount_in_pennies => amount_in_pennies,
10
+ :posted_at => posted_at
11
+ }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,61 @@
1
+ module SaltParser
2
+ module Ofx
3
+ class Builder < SaltParser::Builder
4
+
5
+ def initialize(resource)
6
+ resource = open_resource(resource)
7
+ resource.rewind
8
+ begin
9
+ @content = convert_to_utf8(resource.read)
10
+ prepare(content)
11
+ rescue Exception
12
+ raise SaltParser::Error::UnsupportedFileError
13
+ end
14
+
15
+ @parser = case headers["VERSION"]
16
+ when /102|103/ then
17
+ SaltParser::Ofx::Parser::OFX102.new(:headers => headers, :body => body)
18
+ when /200|202|211/ then
19
+ SaltParser::Ofx::Parser::OFX211.new(:headers => headers, :body => body)
20
+ else
21
+ raise SaltParser::Error::UnsupportedFileError
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def prepare(content)
28
+ # split headers & body
29
+ header_text, body_text = content.dup.split(/<OFX>/, 2)
30
+ header_text.gsub!("encoding=\"USASCII\"", "encoding=\"US-ASCII\"") if header_text.include?("encoding=\"USASCII\"")
31
+
32
+ raise SaltParser::Error::UnsupportedFileError unless body_text
33
+
34
+ @headers = extract_headers(header_text)
35
+ @body = extract_body(body_text)
36
+ end
37
+
38
+ def extract_headers(header_text)
39
+ # Header format is different among versions. Give each
40
+ # parser a chance to parse the headers.
41
+ headers = nil
42
+
43
+ SaltParser::Ofx::Parser.constants.grep(/OFX/).each do |name|
44
+ headers = SaltParser::Ofx::Parser.const_get(name).parse_headers(header_text)
45
+ break if headers
46
+ end
47
+
48
+ raise SaltParser::Error::UnsupportedFileError if headers.empty?
49
+ headers
50
+ end
51
+
52
+ def extract_body(body_text)
53
+ # Replace body tags to parse it with Nokogiri
54
+ body_text.gsub!(/>\s+</m, "><")
55
+ body_text.gsub!(/\s+</m, "<")
56
+ body_text.gsub!(/>\s+/m, ">")
57
+ body_text.gsub!(/<(\w+?)>([^<]+)/m, '<\1>\2</\1>')
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,13 @@
1
+ require "nokogiri"
2
+ require "bigdecimal"
3
+ require "kconv"
4
+
5
+ require_relative "builder"
6
+ require_relative "parser/base"
7
+ require_relative "parser/ofx102"
8
+ require_relative "parser/ofx211"
9
+ require_relative "balance"
10
+ require_relative "account"
11
+ require_relative "accounts"
12
+ require_relative "sign_on"
13
+ require_relative "transaction"
@@ -0,0 +1,366 @@
1
+ module SaltParser
2
+ module Ofx
3
+ module Parser
4
+ class Base
5
+ ACCOUNT_TYPES = {
6
+ "CHECKING" => :checking,
7
+ "SAVINGS" => :savings,
8
+ "CREDITCARD" => :credit_card,
9
+ "CREDITLINE" => :credit,
10
+ "INVESTMENT" => :investment,
11
+ "MONEYMRKT" => :savings
12
+ }
13
+
14
+ TRANSACTION_TYPES = [
15
+ 'ATM', 'CASH', 'CHECK', 'CREDIT', 'DEBIT', 'DEP', 'DIRECTDEBIT', 'DIRECTDEP', 'DIV',
16
+ 'FEE', 'INT', 'OTHER', 'PAYMENT', 'POS', 'REPEATPMT', 'SRVCHG', 'XFER'
17
+ ].inject({}) { |hash, tran_type| hash[tran_type] = tran_type.downcase.to_sym; hash }
18
+
19
+ attr_reader :headers, :body, :html, :errors, :accounts, :sign_on
20
+
21
+ def initialize(options = {})
22
+ @headers = options[:headers]
23
+ @body = options[:body]
24
+ @html = Nokogiri::HTML.parse(body)
25
+ @errors = []
26
+ build_sign_on
27
+ build_accounts
28
+ check_for_errors
29
+ end
30
+
31
+ def to_hash
32
+ {
33
+ :errors => errors,
34
+ :sign_on => sign_on.to_hash,
35
+ :accounts => accounts.to_hash
36
+ }
37
+ end
38
+
39
+ def build_accounts
40
+ @accounts = Ofx::Accounts.new
41
+ build_bank_account
42
+ build_credit_card_account
43
+ build_investments_account
44
+ end
45
+
46
+ private
47
+
48
+ def check_for_errors
49
+ statuses = html.search("status").reverse
50
+ statuses.each do |status|
51
+ if status.search("severity").inner_text == "ERROR"
52
+ errors << SaltParser::Error::RequestError.new(["[#{status.search("code").inner_text}]", status.search("message").inner_text].join(" ").strip)
53
+ end
54
+ end
55
+ end
56
+
57
+ def build_bank_account
58
+ html.search("stmttrnrs", "bankacctinfo").each do |account|
59
+ begin
60
+ account_id = account.search("bankacctfrom > acctid").inner_text
61
+
62
+ @accounts << SaltParser::Ofx::Account.new(
63
+ :bank_id => account.search("bankacctfrom > bankid").inner_text,
64
+ :id => account_id,
65
+ :name => account.parent.search("desc").inner_text,
66
+ :type => ACCOUNT_TYPES[account.search("bankacctfrom > accttype").inner_text.to_s.upcase],
67
+ :transactions => build_transactions(account.search("banktranlist > stmttrn"), account_id),
68
+ :balance => build_balance(account),
69
+ :available_balance => build_available_balance(account),
70
+ :currency => account.search("stmtrs > curdef").inner_text
71
+ )
72
+ rescue SaltParser::Error::ParseError => error
73
+ errors << error
74
+ end
75
+ end
76
+ end
77
+
78
+ def build_credit_card_account
79
+ html.search("ccstmttrnrs", "acctinfo").each do |account|
80
+ begin
81
+ account_id = account.search("ccacctfrom > acctid").inner_text
82
+ next if account_id.blank?
83
+ @accounts << SaltParser::Ofx::Account.new(
84
+ :id => account_id,
85
+ :name => account.search("desc").inner_text,
86
+ :type => ACCOUNT_TYPES["CREDITCARD"],
87
+ :transactions => build_transactions(account.search("banktranlist > stmttrn"), account_id),
88
+ :balance => build_balance(account),
89
+ :currency => account.search("curdef").inner_text
90
+ )
91
+ rescue SaltParser::Error::ParseError => error
92
+ errors << error
93
+ end
94
+ end
95
+ end
96
+
97
+ def build_investments_account
98
+ html.search("invstmttrnrs", "acctinfo").each do |account|
99
+ begin
100
+ account_id = account.search("invacctfrom > acctid").inner_text
101
+ broker_id = account.search("invacctfrom > brokerid").inner_text
102
+
103
+ next if broker_id.blank? or account_id.blank?
104
+ @accounts << SaltParser::Ofx::Account.new(
105
+ :id => account_id,
106
+ :broker_id => broker_id,
107
+ :type => ACCOUNT_TYPES["INVESTMENT"],
108
+ :transactions => build_investment_transactions(account.search("invtranlist"), account_id),
109
+ :balance => build_investment_balance(account),
110
+ :currency => account.search("curdef").inner_text,
111
+ :units => compute_investment_units(account),
112
+ :unit_price => compute_investment_unit_price(account)
113
+ )
114
+ rescue SaltParser::Error::ParseError => error
115
+ errors << error
116
+ end
117
+ end
118
+ end
119
+
120
+ def build_transactions(transactions, account_id)
121
+ transactions.each_with_object([]) do |transaction, transactions|
122
+ begin
123
+ transactions << build_transaction(transaction, account_id)
124
+ rescue SaltParser::Error::ParseError => error
125
+ errors << error
126
+ end
127
+ end
128
+ end
129
+
130
+ def build_transaction(transaction, account_id)
131
+ SaltParser::Ofx::Transaction.new(
132
+ :amount => build_amount(transaction),
133
+ :amount_in_pennies => ((build_amount(transaction) * 100).round 2).to_i,
134
+ :fit_id => transaction.search("fitid").inner_text,
135
+ :memo => transaction.search("memo").inner_text,
136
+ :name => transaction.search("name").inner_text,
137
+ :payee => transaction.search("payee").inner_text,
138
+ :check_number => transaction.search("checknum").inner_text,
139
+ :ref_number => transaction.search("refnum").inner_text,
140
+ :posted_at => build_date(transaction.search("dtposted").inner_text),
141
+ :type => build_type(transaction),
142
+ :sic => transaction.search("sic").inner_text,
143
+ :account_id => account_id
144
+ )
145
+ end
146
+
147
+ def build_investment_transactions(transactions_xml, account_id)
148
+ transactions = transactions_xml.search("stmttrn", "invtran")
149
+ transactions.each_with_object([]) do |transaction, transactions|
150
+ begin
151
+ if transaction.name.include?("stmttrn")
152
+ transactions << build_investment_transaction(transaction, account_id)
153
+ else
154
+ transactions << build_investment_transaction(transaction.parent, account_id)
155
+ end
156
+ rescue SaltParser::Error::ParseError => error
157
+ errors << error
158
+ end
159
+ end
160
+ end
161
+
162
+ def build_investment_transaction(transaction, account_id)
163
+ SaltParser::Ofx::Transaction.new(
164
+ :amount => build_investment_amount(transaction),
165
+ :amount_in_pennies => ((build_investment_amount(transaction) * 100).round 2).to_i,
166
+ :fit_id => transaction.search("fitid").inner_text,
167
+ :memo => transaction.search("memo").inner_text,
168
+ :name => transaction.search("name").inner_text,
169
+ :posted_at => build_date(transaction.search("dtposted", "dttrade").inner_text),
170
+ :type => build_type(transaction),
171
+ :ref_number => transaction.search("refnum", "uniqueid").empty? ? "N/A" : transaction.search("refnum", "uniqueid").inner_text,
172
+ :account_id => account_id,
173
+ :units => parse_float(transaction.search("units").inner_text),
174
+ :unit_price => parse_float(transaction.search("unitprice").inner_text)
175
+ )
176
+ end
177
+
178
+ def build_sign_on
179
+ @sign_on = SaltParser::Ofx::SignOn.new(
180
+ :language => html.search("signonmsgsrsv1 > sonrs > language").inner_text,
181
+ :fi_id => html.search("signonmsgsrsv1 > sonrs > fi > fid").inner_text,
182
+ :fi_name => html.search("signonmsgsrsv1 > sonrs > fi > org").inner_text,
183
+ :code => html.search("signonmsgsrsv1 > sonrs > status > code").inner_text,
184
+ :severity => html.search("signonmsgsrsv1 > sonrs > status > severity").inner_text,
185
+ :message => html.search("signonmsgsrsv1 > sonrs > status > message").inner_text
186
+ )
187
+ end
188
+
189
+ def build_balance(account)
190
+ return nil unless account.search("ledgerbal > balamt").size > 0
191
+
192
+ if account.search("ledgerbal > balamt").inner_text.match(/[\d]{14}\.[\d]+/)
193
+ SaltParser::Ofx::Balance.new(
194
+ :amount => 0.0,
195
+ :amount_in_pennies => 0,
196
+ :posted_at => build_date(account.search("ledgerbal > balamt").inner_text)
197
+ )
198
+ else
199
+ amount = parse_float(account.search("ledgerbal > balamt").inner_text)
200
+
201
+ SaltParser::Ofx::Balance.new(
202
+ :amount => amount,
203
+ :amount_in_pennies => ((amount * 100).round 2).to_i,
204
+ :posted_at => build_date(account.search("ledgerbal > dtasof").inner_text)
205
+ )
206
+ end
207
+ end
208
+
209
+ def build_available_balance(account)
210
+ return nil unless account.search("availbal").size > 0
211
+
212
+ if account.search("availbal > balamt").inner_text.match(/[\d]{14}\.[\d]+/)
213
+ SaltParser::Ofx::Balance.new(
214
+ :amount => 0.0,
215
+ :amount_in_pennies => 0,
216
+ :posted_at => build_date(account.search("availbal > balamt").inner_text)
217
+ )
218
+ else
219
+ amount = parse_float(account.search("availbal > balamt").inner_text)
220
+
221
+ SaltParser::Ofx::Balance.new(
222
+ :amount => amount,
223
+ :amount_in_pennies => ((amount * 100).round 2).to_i,
224
+ :posted_at => build_date(account.search("availbal > dtasof").inner_text)
225
+ )
226
+ end
227
+ end
228
+
229
+ def build_investment_balance(account)
230
+ if account.search("invbal > availcash").size > 0 && account.search("invpos > mktval").size < 1
231
+ amount = parse_float(account.search("invbal > availcash").inner_text)
232
+
233
+ elsif account.search("invbal > availcash").size < 1 && account.search("invpos > mktval").size > 0
234
+ amount = 0
235
+ account.search("invpos > mktval").map do |mktval|
236
+ amount += parse_float(mktval.inner_text)
237
+ end
238
+ amount
239
+
240
+ elsif account.search("invbal > availcash").size > 0 && account.search("invpos > mktval").size > 0
241
+ amount = 0
242
+ account.search("invbal > availcash").map do |availcash|
243
+ amount += parse_float(availcash.inner_text)
244
+ end
245
+ account.search("invpos > mktval").map do |mktval|
246
+ amount += parse_float(mktval.inner_text)
247
+ end
248
+ amount
249
+
250
+ else
251
+ return nil
252
+ end
253
+
254
+ SaltParser::Ofx::Balance.new(
255
+ :amount => amount,
256
+ :amount_in_pennies => ((amount * 100).round 2).to_i
257
+ )
258
+ end
259
+
260
+ def compute_investment_units(account)
261
+ if account.search("invpos > units").size == 1
262
+ parse_float(account.search("invpos > units").inner_text)
263
+ else
264
+ 0.0
265
+ end
266
+ end
267
+
268
+ def compute_investment_unit_price(account)
269
+ if account.search("invpos > unitprice").size == 1
270
+ parse_float(account.search("invpos > unitprice").inner_text)
271
+ else
272
+ 0.0
273
+ end
274
+ end
275
+
276
+ def build_type(element)
277
+ TRANSACTION_TYPES[element.search("trntype", "incometype").inner_text.to_s.upcase]
278
+ end
279
+
280
+ def build_amount(element)
281
+ parse_float(element.search("trnamt", "total").inner_text)
282
+ rescue TypeError => error
283
+ raise SaltParser::Ofx::ParseError.new(SaltParser::Ofx::ParseError::AMOUNT)
284
+ end
285
+
286
+ def build_investment_amount(element)
287
+ if element.parent.search("invbuy", "invsell").size > 0
288
+ -1 * parse_float(element.search("total").inner_text)
289
+ else
290
+ build_amount(element)
291
+ end
292
+ rescue TypeError => error
293
+ raise SaltParser::Error::ParseError.new(SaltParser::Error::ParseError::AMOUNT)
294
+ end
295
+
296
+ def build_date(date)
297
+ _, year, month, day, hour, minutes, seconds = *date.match(/(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?/)
298
+
299
+ date = "#{year}-#{month}-#{day} "
300
+ date << "#{hour}:#{minutes}:#{seconds}" if hour && minutes && seconds
301
+
302
+ Time.parse(date)
303
+ rescue TypeError, ArgumentError => error
304
+ raise SaltParser::Error::ParseError.new(SaltParser::Error::ParseError::TIME)
305
+ end
306
+
307
+ def parse_float(incoming, options={})
308
+ return incoming if incoming.is_a?(Float)
309
+ string = incoming.dup
310
+ sanitize_float_string!(string)
311
+
312
+ if options[:integral]
313
+ string.gsub!(",", "")
314
+ string.gsub!(".", "")
315
+ return string.to_f
316
+ end
317
+
318
+ indexes = {
319
+ "," => string.rindex(","),
320
+ "." => string.rindex(".")
321
+ }
322
+
323
+ return string.to_f if indexes["."].nil? && indexes[","].nil?
324
+
325
+ if indexes["."] == nil
326
+ if string.scan(/,/).size > 1
327
+ string.gsub!(",", "") # 123,123,123
328
+ else
329
+ string.gsub!(",", ".") # 123,123
330
+ end
331
+ return string.to_f
332
+ end
333
+
334
+ if indexes[","] == nil
335
+ string.gsub!(".", "") if string.scan(/\./).size > 1 # 123.123.123
336
+ return string.to_f
337
+ end
338
+
339
+ if indexes[","] > indexes["."]
340
+ # comma is decimal separator
341
+ string.gsub!(".", "")
342
+ string.gsub!(",", ".")
343
+ else
344
+ # dot is decimal separator
345
+ string.gsub!(",", "")
346
+ end
347
+
348
+ string.to_f
349
+ rescue => error
350
+ raise SaltParser::Error::ParseError.new(SaltParser::Error::ParseError::FLOAT)
351
+ end
352
+
353
+ def sanitize_float_string!(string)
354
+ # replace weird minus sign with proper minus
355
+ string.gsub!(8211.chr(Encoding::UTF_8), "-")
356
+ # replace an even weirder minus sign with proper minus
357
+ string.gsub!(8722.chr(Encoding::UTF_8), "-")
358
+ # remove everything except digits, dots, commas, '+', '-'
359
+ string.gsub!(/[^0-9\-+.,]/, "")
360
+ # remove trailing non digits
361
+ string.gsub!(/[-+.,]+$/, "")
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end