dinero 0.0.1 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5127bb025bcedb230c70da3c94fb2c79d51d2d2e
4
- data.tar.gz: df996cbf0a393e271f07c7157b9532e7352b75ee
3
+ metadata.gz: 54db7e4b1d206d902c99a0bc1240c2e9f9c69cde
4
+ data.tar.gz: 0e5c793a6055e095edd3849a91ddd7368fca5e48
5
5
  SHA512:
6
- metadata.gz: c7c9e8640651381ce48fbfa84be999b9c60ae87b4e2bd533c93c6bd1abeac2ddd5f2ddf4ba1c430d485240f898d2b6fc255c2ccb96972a422440222799b35791
7
- data.tar.gz: ce83fea40a4f76ac91c9fdea94092e7e28508604679be3bde318b5c3ea1c00989cc9f37d915e86795a23b7076089e94ad3e91df216f54ed4d81a836d9caa66b6
6
+ metadata.gz: 2794d5f6d5338f69d586e9a1b7ba146a6d597b6e6eeb52d4cc1df3e207200db5591888cd3965723b2f0423be30fbbb1617e7ca97f2c3ec33ed82fcde8ed406ac
7
+ data.tar.gz: cce7bb0830d8644a4539d728dc882cca84e5ff7f082283f77027479b5fb32e4547a808684e6c52f880782808befaaec2a2a4cc2943f2a97f26a4f7dcbdcf4b9c
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/Gemfile.lock CHANGED
@@ -1,39 +1,40 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dinero (0.0.1)
4
+ dinero (0.0.3)
5
5
  nokogiri (~> 1.6.6)
6
- selenium-webdriver (~> 2.45.0)
6
+ selenium-webdriver (~> 3.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- addressable (2.3.8)
11
+ addressable (2.5.0)
12
+ public_suffix (~> 2.0, >= 2.0.2)
12
13
  byebug (4.0.5)
13
14
  columnize (= 0.9.0)
14
- childprocess (0.5.6)
15
+ childprocess (0.5.9)
15
16
  ffi (~> 1.0, >= 1.0.11)
16
- coderay (1.1.0)
17
+ coderay (1.1.1)
17
18
  columnize (0.9.0)
18
- crack (0.4.2)
19
+ crack (0.4.3)
19
20
  safe_yaml (~> 1.0.0)
20
21
  diff-lcs (1.2.5)
21
22
  docile (1.1.5)
22
- ffi (1.9.8)
23
- json (1.8.2)
23
+ ffi (1.9.14)
24
+ json (1.8.3)
24
25
  method_source (0.8.2)
25
- mini_portile (0.6.2)
26
- multi_json (1.11.0)
27
- nokogiri (1.6.6.2)
28
- mini_portile (~> 0.6.0)
29
- pry (0.10.1)
26
+ mini_portile2 (2.1.0)
27
+ nokogiri (1.6.8.1)
28
+ mini_portile2 (~> 2.1.0)
29
+ pry (0.10.4)
30
30
  coderay (~> 1.1.0)
31
31
  method_source (~> 0.8.1)
32
32
  slop (~> 3.4)
33
33
  pry-byebug (3.1.0)
34
34
  byebug (~> 4.0)
35
35
  pry (~> 0.10)
36
- rake (10.4.2)
36
+ public_suffix (2.0.4)
37
+ rake (11.3.0)
37
38
  rspec (3.2.0)
38
39
  rspec-core (~> 3.2.0)
39
40
  rspec-expectations (~> 3.2.0)
@@ -50,11 +51,10 @@ GEM
50
51
  diff-lcs (>= 1.2.0, < 2.0)
51
52
  rspec-support (~> 3.2.0)
52
53
  rspec-support (3.2.2)
53
- rubyzip (1.1.7)
54
+ rubyzip (1.2.0)
54
55
  safe_yaml (1.0.4)
55
- selenium-webdriver (2.45.0)
56
+ selenium-webdriver (3.0.3)
56
57
  childprocess (~> 0.5)
57
- multi_json (~> 1.0)
58
58
  rubyzip (~> 1.0)
59
59
  websocket (~> 1.0)
60
60
  simplecov (0.10.0)
@@ -67,7 +67,7 @@ GEM
67
67
  webmock (1.21.0)
68
68
  addressable (>= 2.3.6)
69
69
  crack (>= 0.3.2)
70
- websocket (1.2.2)
70
+ websocket (1.2.3)
71
71
 
72
72
  PLATFORMS
73
73
  ruby
@@ -82,3 +82,6 @@ DEPENDENCIES
82
82
  simplecov (~> 0.10.0)
83
83
  vcr (~> 2.9.3)
84
84
  webmock (~> 1.21.0)
85
+
86
+ BUNDLED WITH
87
+ 1.12.5
data/README.md CHANGED
@@ -13,11 +13,11 @@ Banks are rightly concerned about security, anti-phishing, and general brute for
13
13
  So much trickery requires something more than just Mechanize or RestClient. Dinero uses Selenium and PhantomJS to drive the data collection. So, you'll need to install Selenium before you can begin using Dinero. If you're on a mac:
14
14
 
15
15
  brew install selenium-server-standalone
16
-
17
- And then:
16
+
17
+ And then:
18
18
 
19
19
  gem install dinero
20
-
20
+
21
21
  ## The Vision
22
22
 
23
23
  Much like ActiveRecord sought to standardize the API for accessing and modeling domain data in the DBMS without having to drop down to raw SQL and as ActiveMerchant seeks to standardize payment processing to a common set of API's, Dinero aims to standardize access to bank accounts. To that end, I have started implementing all the banks I have accounts with.
@@ -28,8 +28,9 @@ The following banks are implemented:
28
28
 
29
29
  * Capital One - https://capitalone.com (only U.S. credit card logins -- there's also banking, loans, investing, business, and Canada/UK credit cards)
30
30
  * Capital One 360 - https://capitalone360.com (formerly Ing Direct).
31
+ * South State Bank - https://www.southstatebank.com/
31
32
 
32
- Currently, only Accounts balances are essentially implemented. The following properties are available on each Account:
33
+ Currently, only Accounts balances are implemented. The following properties are available on each Account:
33
34
 
34
35
  * account_type -- one of :bank, :brokerage, :credit_card
35
36
  * name -- the name of the account (e.g. "Checking 360 - primary", "Worldview MasterCard")
@@ -39,7 +40,7 @@ Currently, only Accounts balances are essentially implemented. The following pr
39
40
 
40
41
  ## How to Use
41
42
 
42
- You'll find at least one example in the examples folder called 'get_balances.' This example can take a bank_name, username, and password and print out balances to the console something like this:
43
+ You'll find at least one example in the examples folder called 'get_balances.rb' This example can take a bank_name, username, and password and print out balances to the console something like this:
43
44
 
44
45
  ~~~ bash
45
46
  >> bundle exec ruby examples/get_balances.rb --bank capital_one_360 --user scrooge
@@ -63,16 +64,29 @@ So, yeah, with more banks, we can collect more info. The above shows banking ac
63
64
 
64
65
  To use the gem inside your app:
65
66
 
66
- ~~~
67
+ ~~~ ruby
67
68
  require 'dinero'
68
69
 
69
- bank_info = CapitalOne360.new(username: @username, password: @password))
70
+ bank_info = Dinero::Bank::CapitalOne360.new(username: @username, password: @password))
70
71
  bank_info.accounts.each do |acct|
71
- puts [acct.name, acct.number, acct.account_type, acct.balance, acct.available].join("\t")
72
+ puts [acct.name, acct.number, acct.account_type, acct.balance, acct.available].join("\t")
73
+ end
72
74
  ~~~
73
75
 
74
76
  If you have a really slow Bank site or Internet connection, try passing ```timeout: 15``` when initializing a Bank class.
75
77
 
78
+ For banks that ask security questions on new computers (such as South State Bank), supply an array of question/answer hashes as "security_questions" when instantiating the Bank object. For example:
79
+
80
+ ~~~ ruby
81
+ answers = [
82
+ {"question" => "What is your favorite hobby?", "answer"=>"ruby"},
83
+ {"question" => "What is your father's middle name?", "answer"=>"smith"},
84
+ {"question" => "What was your first job?", "answer" => "student"}
85
+ ]
86
+ bank_info = Dinero::Bank::CapitalOne360.new(username: @username, password: @password, security_questions: answers))
87
+ # ...
88
+ ~~~
89
+
76
90
  ## Contribute!
77
91
 
78
92
  I'm planning to continue implementing more banks, but I can use your help since I don't have access to all the world's banks.
@@ -83,10 +97,7 @@ I plan to implement the following banks:
83
97
  * Scottrade (brokerage, IRA, and Bank Account)
84
98
  * San Diego County Credit Union
85
99
  * Georgia's Own Credit Union
86
- * South State Bank
87
100
 
88
- I know I'll need to do those fun security questions for unregistered browsers on some of these. I haven't quite decided on how to structure this, but at least the Bank class has an open-ended options hash parameter to accommodate additional fields being passed in.
89
-
90
101
  If you want to add a new bank, here's how:
91
102
 
92
103
  # Pick one of the existing banks that most closely follows the login pattern of your chosen bank and model your effort after it.
@@ -100,18 +111,32 @@ Here's an example banks.yml file:
100
111
  capital_one_360:
101
112
  username: mickeymouse
102
113
  password: moosamoosamickeymouse
103
- account_types:
114
+ account_types:
104
115
  - :bank
105
116
  - :brokerage
106
117
  - :credit_card
107
118
  accounts: 3
108
-
119
+
109
120
  capital_one:
110
121
  username: mickeymouse
111
122
  password: moosamoosamickeymouse
112
- account_types:
123
+ account_types:
113
124
  - :credit_card
114
125
  accounts: 2
126
+
127
+ south_state_bank:
128
+ username: mickeymouse
129
+ password: moosamoosamickeymouse
130
+ account_types:
131
+ - :bank
132
+ accounts: 3
133
+ security_questions:
134
+ - question: "What is your favorite hobby?"
135
+ answer: ruby
136
+ - question: "What is your father's middle name?"
137
+ answer: smith
138
+ - question: "What was your first job?"
139
+ answer: student
115
140
  ~~~
116
141
 
117
142
  The bank rspecs are wrapped with ```if bank_configured? :capital_one_360``` if block that allows the spec to run or not, so if the first thing you did was 'rspec' and saw 0 examples, that means you don't have a banks.yml file, yet -- or it's incorrectly configured.
@@ -147,7 +172,7 @@ def post_username!
147
172
  end
148
173
  ~~~
149
174
 
150
- ### #post_password!
175
+ ### #post_password!
151
176
  Here, key the password and submit the form. You may have to get the handle on the button and call the button.click method instead of simply calling form.submit as some banks put JavaScript here to make sure the button's being clicked rather than automated submittals via scripts. The implementation should look something like this:
152
177
 
153
178
  ~~~ ruby
data/dinero.gemspec CHANGED
@@ -28,6 +28,6 @@ Gem::Specification.new do |spec|
28
28
  spec.add_development_dependency "simplecov", "~> 0.10.0"
29
29
  spec.add_development_dependency "pry-byebug", "~> 3.1.0"
30
30
 
31
- spec.add_dependency 'selenium-webdriver', "~> 2.45.0"
31
+ spec.add_dependency 'selenium-webdriver', "~> 3.0"
32
32
  spec.add_dependency "nokogiri", "~> 1.6.6"
33
33
  end
@@ -6,7 +6,7 @@ while !args.empty? do
6
6
  if option =~ /\A\-\-/
7
7
  value = args.shift
8
8
  end
9
- case option
9
+ case option
10
10
  when "--bank" then @bank = value
11
11
  when "--user" then @username = value
12
12
  when "--password" then @password = value
@@ -42,7 +42,7 @@ else
42
42
  usage: bundle exec ruby examples/get_balances.rb --bank <bank_name> --user <login_account_name> [--password <login_password>]
43
43
 
44
44
  * bank_name needs to match one of the class_names supported in the lib/banks folder.
45
- * if password omitted, you'll be prompted to supply one.
46
-
45
+ * if password omitted, you'll be prompted to supply one.
46
+
47
47
  USAGE
48
- end
48
+ end
@@ -1,4 +1,5 @@
1
1
  module Dinero
2
+ NUMERIC_REGEXP = /[\d|\-|\.]+/
2
3
  class Account
3
4
  attr_reader :account_type, :name, :name_other, :number, :balance, :available
4
5
  def initialize account_type, name, number, balance, available
@@ -7,8 +8,8 @@ module Dinero
7
8
  @name = name_parts.shift
8
9
  @name_other = name_parts.join("\n")
9
10
  @number = number
10
- @balance = balance.scan(/[\d|\-|\.]+/).join.to_f
11
- @available = available.scan(/[\d|\-|\.]+/).join.to_f
11
+ @balance = balance.scan(NUMERIC_REGEXP).join.to_f
12
+ @available = available.scan(NUMERIC_REGEXP).join.to_f
12
13
  end
13
14
  end
14
- end
15
+ end
@@ -1,54 +1,58 @@
1
1
  module Dinero
2
2
  module Bank
3
3
  class CapitalOne < Base
4
- LOGIN_URL = "https://servicing.capitalone.com/c1/Login.aspx"
5
- ACCOUNTS_SUMMARY_PATH = "/accounts"
6
- CONNECTION_TIMEOUT = 10
4
+ LOGIN_URL = "https://verified.capitalone.com/sic-ui/#/esignin?Product=Card"
5
+ ACCOUNTS_SUMMARY_PATH = "/accounts/"
6
+ CONNECTION_TIMEOUT = 30
7
7
 
8
8
  def default_options
9
9
  { timeout: CONNECTION_TIMEOUT, login_url: LOGIN_URL }
10
10
  end
11
11
 
12
12
  def post_username!
13
- connection.switch_to.frame "loginframe"
14
- wait.until { connection.find_element(id: "uname") }
15
- username_field = connection.find_element(id: "uname")
13
+ wait.until { connection.find_element(id: "id-signin-form") }
14
+ @signin_form = connection.find_element(id: "id-signin-form")
15
+ username_field = @signin_form.find_element(id: "username")
16
16
  username_field.send_keys username
17
17
  end
18
-
18
+
19
19
  def post_password!
20
- signin_form = connection.find_element(name: "login")
21
- password_field = signin_form.find_element(id: "cofisso_ti_passw")
22
- login_btn = signin_form.find_element(id: "cofisso_btn_login")
23
-
20
+ password_field = @signin_form.find_element(id: "password")
24
21
  password_field.send_keys password
22
+
23
+ login_btn = @signin_form.find_element(id: "id-signin-submit")
25
24
  login_btn.click
26
25
  end
27
-
26
+
28
27
  def post_credentials!
29
28
  post_username!
30
29
  post_password!
31
30
  end
32
-
31
+
33
32
  def after_successful_login
34
- # the subdomain frequently changes, so capture the actual URL
33
+ # the subdomain frequently changes, so capture the actual URL
35
34
  # so we can return to the page if necessary.
36
35
  @accounts_summary_url = connection.current_url
37
36
  end
38
-
37
+
39
38
  def on_accounts_summary_page?
40
39
  URI(connection.current_url).path == ACCOUNTS_SUMMARY_PATH
41
40
  end
42
-
41
+
43
42
  def goto_accounts_summary_page
44
43
  return if authenticated? && on_accounts_summary_page?
45
44
  authenticated? ? connection.navigate.to(@accounts_summary_url) : login!
45
+ wait.until { connection.find_element(id: "main_content") }
46
+ end
47
+
48
+ def first_numeric value
49
+ value.split("\n").reject{|r| r.empty?}.first
46
50
  end
47
-
51
+
48
52
  # extract account data from the account summary page
49
53
  def accounts
50
54
  return @accounts if @accounts
51
-
55
+
52
56
  # find the bricklet articles, which contains the balance data
53
57
  articles = accounts_summary_document.xpath("//article").
54
58
  select{|a| a.attributes["class"].value == "bricklet"}
@@ -58,10 +62,10 @@ module Dinero
58
62
  name = article.xpath(".//a[@class='product_desc_link']").text
59
63
  number = article.xpath(".//span[@id='#{prefix}_number']").text
60
64
  balance = article.xpath(".//span[@id='#{prefix}_current_balance_amount']").text
61
- credit = article.xpath(".//div[@id='#{prefix}_available_credit_amount']").text.split("\n").first
62
- Account.new(:credit_card, name, number, balance, credit)
65
+ available = first_numeric(article.xpath(".//div[@id='#{prefix}_available_credit_amount']").text)
66
+ Account.new(:credit_card, name, number, balance, available)
63
67
  end
64
68
  end
65
69
  end
66
70
  end
67
- end
71
+ end
@@ -3,7 +3,7 @@ module Dinero
3
3
  class CapitalOne360 < Base
4
4
  LOGIN_URL = "https://secure.capitalone360.com/myaccount/banking/login.vm"
5
5
  ACCOUNTS_SUMMARY_URL = "https://secure.capitalone360.com/myaccount/banking/account_summary.vm"
6
-
6
+
7
7
  def default_options
8
8
  { login_url: LOGIN_URL }
9
9
  end
@@ -19,11 +19,11 @@ module Dinero
19
19
  signin_form = connection.find_element(id: "Signin")
20
20
  username_field = connection.find_element(id: "ACNID")
21
21
  raise "Sign in Form not reached!" unless username_field && signin_form
22
-
22
+
23
23
  username_field.send_keys username
24
24
  signin_form.submit
25
25
  end
26
-
26
+
27
27
  def post_password!
28
28
  begin
29
29
  wait.until { connection.find_element(id: "PasswordForm").displayed? }
@@ -40,12 +40,19 @@ module Dinero
40
40
  password_field.send_keys password
41
41
  submit_button.click
42
42
  end
43
-
43
+
44
+ def post_credentials!
45
+ post_username!
46
+ post_password!
47
+ end
48
+
44
49
  def accounts_summary_page_fully_loaded?
45
50
  tables = connection.find_elements css: 'table'
46
51
  !(tables.empty? or tables.detect{|t| t.text =~ /\sLoading/})
52
+ rescue Selenium::WebDriver::Error::StaleElementReferenceError => error
53
+ false
47
54
  end
48
-
55
+
49
56
  def on_accounts_summary_page?
50
57
  connection.current_url == ACCOUNTS_SUMMARY_URL
51
58
  end
@@ -55,18 +62,18 @@ module Dinero
55
62
  authenticated? ? connection.navigate.to(ACCOUNTS_SUMMARY_URL) : login!
56
63
  wait.until { accounts_summary_page_fully_loaded? }
57
64
  end
58
-
65
+
59
66
  def balance_row? row
60
67
  row[1] =~ /Total/
61
68
  end
62
-
69
+
63
70
  def promo_table? table
64
71
  table.empty? or table[0].empty? or table[0][0].empty?
65
72
  end
66
73
 
67
74
  def decipher_account_type title
68
75
  return :credit_card if title =~ /Credit Cards/
69
- return :brokerage if title =~ /ShareBuilder/
76
+ return :brokerage if title =~ /Investing/
70
77
  return :bank if title =~ /Checking/
71
78
  return :unknown
72
79
  end
@@ -75,46 +82,53 @@ module Dinero
75
82
  return unless value
76
83
  value.split("\u00A0").first.strip
77
84
  end
78
-
85
+
79
86
  # extract account data from the account summary page
80
87
  def accounts
81
88
  return @accounts if @accounts
82
89
  @accounts = []
83
-
84
- # lots of spaces, tabs and the #00A0 characters, so extract
85
- # text with this extraneous junk suppressed.
86
- tables = accounts_summary_document.xpath("//table")
87
- account_tables = tables.map do |table|
88
- rows = table.xpath(".//tr").map{|row| row.xpath(".//td|.//th").
89
- map{|cell| cell.text.strip.gsub(/\s+|\t/, " ")}}
90
- end.reject{|table| promo_table? table }
91
-
92
- # Turn tablular data into Account classes
93
- account_tables.map do |table|
94
-
95
- # the header row tells us what kind of account we're looking at
96
- header = table.shift
97
- account_type = decipher_account_type header[0]
98
- has_account_number = header[1] =~ /Account/
99
-
100
- # ignore balance rows at bottom of tables
101
- rows = table.reject{|row| balance_row? row }
102
-
103
- # turn those rows into accounts
104
- rows.each do |row|
105
- name = sanitize(row.shift)
106
- number = (has_account_number ? sanitize(row.shift) : nil)
107
- if number.nil? || name =~ /(\.{4})(\d+)\Z/
108
- number = name.match(/(\.{4})(\d+)\Z/).captures.join
109
- name = name.gsub(number,'')
90
+
91
+ begin
92
+ # lots of spaces, tabs and the #00A0 characters, so extract
93
+ # text with this extraneous junk suppressed.
94
+ tables = accounts_summary_document.xpath("//table")
95
+ account_tables = tables.map do |table|
96
+ rows = table.xpath(".//tr").map{|row| row.xpath(".//td|.//th").
97
+ map{|cell| cell.text.strip.gsub(/\s+|\t/, " ")}}
98
+ end.reject{|table| promo_table? table }
99
+
100
+ # Turn tablular data into Account classes
101
+ account_tables.map do |table|
102
+
103
+ # the header row tells us what kind of account we're looking at
104
+ header = table.shift
105
+ account_type = decipher_account_type header[0]
106
+ has_account_number = header[1] =~ /Account/
107
+
108
+ # ignore balance rows at bottom of tables
109
+ rows = table.reject{|row| balance_row? row }
110
+
111
+ # turn those rows into accounts
112
+ rows.each do |row|
113
+ name = sanitize(row.shift)
114
+ number = (has_account_number ? sanitize(row.shift) : nil)
115
+ if number.nil? || name =~ /(\.{4})(\d+)\Z/
116
+ number = name.match(/(\.{4})(\d+)\Z/).captures.join
117
+ name = name.gsub(number,'')
118
+ end
119
+ balance = row.shift
120
+ available = row.shift || balance
121
+ @accounts << Account.new(account_type, name, number, balance, available)
110
122
  end
111
- balance = row.shift
112
- available = row.shift || balance
113
- @accounts << Account.new(account_type, name, number, balance, available)
114
123
  end
124
+ rescue Exception => error
125
+ connection.save_screenshot('log/accounts.png')
126
+ error.backtrace.each { |line| puts line }
127
+ raise
115
128
  end
129
+
116
130
  return @accounts
117
131
  end
118
132
  end
119
133
  end
120
- end
134
+ end
@@ -0,0 +1,99 @@
1
+ module Dinero
2
+ module Bank
3
+ class SouthStateBank < Base
4
+ LOGIN_URL = "https://www.southstatebank.com/"
5
+ ACCOUNTS_SUMMARY_PATH = "/accounts"
6
+ CONNECTION_TIMEOUT = 10
7
+
8
+ def default_options
9
+ { timeout: CONNECTION_TIMEOUT, login_url: LOGIN_URL }
10
+ end
11
+
12
+ def post_username!
13
+ screenshot_on_error do
14
+ wait.until { connection.find_element(id: "desktop-splash-login") }
15
+ login_form = connection.find_element(id: "desktop_hero_form_online_banking")
16
+ username_field = login_form.find_element(id: "desktop_hero_input_online_banking")
17
+ username_field.send_keys username
18
+
19
+ submit_button = login_form.find_element(xpath: ".//input[@type='submit']")
20
+ submit_button.click
21
+ end
22
+ end
23
+
24
+ def find_answer question
25
+ if q = security_questions.detect{ |qa| qa["question"] == question }
26
+ return q["answer"]
27
+ else
28
+ raise "Unknown security question: #{question.inspect}"
29
+ end
30
+ end
31
+
32
+ def post_security_answer!
33
+ screenshot_on_error do
34
+ wait.until { connection.find_element(id: "nav2t") }
35
+ logon_form = connection.find_element(id: "Logon")
36
+ question_text = logon_form.find_element(xpath: ".//table/tbody/tr/td").text
37
+ answer = find_answer question_text
38
+
39
+ answer_field = logon_form.find_element(id: "QuestionAnswer")
40
+ answer_field.send_keys answer
41
+
42
+ submit_button = logon_form.find_element(id:"Submit")
43
+ submit_button.click
44
+ end
45
+ end
46
+
47
+ def post_password!
48
+ wait.until { connection.find_element(id: "DisplayPassword") }
49
+
50
+ password_field = connection.find_element(id: "DisplayPassword")
51
+ password_field.send_keys password
52
+
53
+ login_btn = connection.find_element(id: "Submit")
54
+ login_btn.click
55
+ end
56
+
57
+ def post_credentials!
58
+ post_username!
59
+ post_security_answer!
60
+ post_password!
61
+ end
62
+
63
+ def after_successful_login
64
+ # the subdomain frequently changes, so capture the actual URL
65
+ # so we can return to the page if necessary.
66
+ @accounts_summary_url = connection.current_url
67
+ end
68
+
69
+ def on_accounts_summary_page?
70
+ connection.page_source =~ /List of Accounts/
71
+ end
72
+
73
+ def goto_accounts_summary_page
74
+ return if authenticated? && on_accounts_summary_page?
75
+ authenticated? ? connection.navigate.to(@accounts_summary_url) : login!
76
+ end
77
+
78
+ def account_table_rows
79
+ accounts_summary_document.xpath("//ul[@class='AccountList-Accounts']/li/table/tbody/tr")
80
+ end
81
+
82
+ # extract account data from the account summary page
83
+ def accounts
84
+ return @accounts if @accounts
85
+
86
+ # find the bricklet articles, which contains the balance data
87
+ @accounts = account_table_rows.map do |row|
88
+ data = row.xpath(".//td").map(&:text)
89
+ number = data.shift
90
+ name = data.shift
91
+ balance = data.pop.scan(NUMERIC_REGEXP).join
92
+ available = data.pop.scan(NUMERIC_REGEXP).join
93
+ available = balance if available.empty?
94
+ Account.new(:bank, name, number, balance, available)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/dinero/banks.rb CHANGED
@@ -3,15 +3,16 @@ module Dinero
3
3
  DEFAULT_TIMEOUT = 5
4
4
 
5
5
  class Base
6
- attr_reader :username, :password
6
+ attr_reader :username, :password, :security_questions
7
7
  attr_reader :timeout, :login_url
8
-
8
+
9
9
  def initialize options
10
10
  opts = default_options.merge options
11
-
11
+
12
12
  @username = opts[:username]
13
13
  @password = opts[:password]
14
14
  @login_url = opts[:login_url]
15
+ @security_questions = opts[:security_questions] || []
15
16
  @timeout = opts[:timeout] || DEFAULT_TIMEOUT
16
17
  @authenticated = false
17
18
  validate!
@@ -22,13 +23,21 @@ module Dinero
22
23
  raise "Must supply :password" if @password.to_s.empty?
23
24
  raise "Must have a :login_url" if @login_url.to_s.empty?
24
25
  end
25
-
26
+
26
27
  def default_options
27
28
  {}
28
29
  end
29
-
30
+
30
31
  def establish_connection
31
- Selenium::WebDriver.for :phantomjs
32
+ capabilities = Selenium::WebDriver::Remote::Capabilities.phantomjs(
33
+ 'acceptSslCerts' => true,
34
+ 'phantomjs.page.settings.userAgent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Firefox/33.0',
35
+ 'service_args' => ['--ignore-ssl-errors=true']
36
+ )
37
+
38
+ driver = Selenium::WebDriver.for :phantomjs, :desired_capabilities => capabilities
39
+ driver.manage.window.size = Selenium::WebDriver::Dimension.new(1640, 768)
40
+ driver
32
41
  end
33
42
 
34
43
  def connection
@@ -42,7 +51,7 @@ module Dinero
42
51
  def wait
43
52
  @wait ||= Selenium::WebDriver::Wait.new(:timeout => timeout)
44
53
  end
45
-
54
+
46
55
  def accounts_summary_document
47
56
  return @accounts_summary_document if @accounts_summary_document
48
57
 
@@ -51,24 +60,48 @@ module Dinero
51
60
  end
52
61
 
53
62
  def after_successful_login
54
- # NOP
63
+ # NOP
64
+ end
65
+
66
+ def class_name
67
+ self.class.to_s.downcase.gsub("dinero::bank::", '')
68
+ end
69
+
70
+ def snap filename
71
+ filename = filename + '.png' unless filename =~ /\.png$/
72
+ connection.save_screenshot "log/#{filename}"
73
+ end
74
+
75
+ def screenshot_on_error name = nil
76
+ begin
77
+ yield
78
+ rescue
79
+ unless name
80
+ class_name, method_name = caller.first.match(/(\w+)\.rb\:\d+\:in\s\`([^\']+)/).captures
81
+ name = "#{class_name}_#{method_name.gsub(/\W/, '')}"
82
+ end
83
+ snap "#{name}_error" unless @captured_error
84
+ @captured_error = true
85
+ raise
86
+ end
87
+ end
88
+
89
+ def goto_login_page
90
+ connection.navigate.to login_url
91
+ snap "#{class_name}_login_page.png"
55
92
  end
56
93
 
57
94
  def login!
58
95
  return if authenticated?
59
- begin
60
- connection.navigate.to login_url
61
- post_username!
62
- post_password!
96
+ screenshot_on_error do
97
+ goto_login_page
98
+ post_credentials!
63
99
  wait.until { on_accounts_summary_page? }
64
100
  after_successful_login
65
101
  @authenticated = true
66
- rescue
67
- connection.save_screenshot('log/#{self.to_s.downcase}_login_failure.png')
68
- raise
69
102
  end
70
103
  end
71
104
 
72
105
  end
73
106
  end
74
- end
107
+ end
@@ -1,3 +1,3 @@
1
1
  module Dinero
2
- VERSION = "0.0.1"
3
- end
2
+ VERSION = "0.0.3"
3
+ end
data/lib/dinero.rb CHANGED
@@ -6,5 +6,6 @@ require_relative 'dinero/version'
6
6
  require_relative 'dinero/banks'
7
7
  require_relative 'dinero/banks/capital_one'
8
8
  require_relative 'dinero/banks/capital_one_360'
9
+ require_relative 'dinero/banks/south_state_bank'
9
10
 
10
11
  require_relative 'dinero/account'
@@ -5,14 +5,14 @@ if bank_configured? :capital_one_360
5
5
  RSpec.describe Dinero::Bank::CapitalOne360 do
6
6
  let(:bank_configuration) { bank_configurations[:capital_one_360] }
7
7
  let(:account_types) { bank_configuration[:account_types].sort }
8
-
8
+
9
9
  before(:all) do
10
- VCR.use_cassette("accounts_capital_one_360") do
10
+ VCR.use_cassette("accounts_capital_one_360", record: :new_episodes) do
11
11
  @bank = Dinero::Bank::CapitalOne360.new(bank_configurations[:capital_one_360])
12
12
  @bank.accounts
13
13
  end
14
14
  end
15
-
15
+
16
16
  it "has expected timeout" do
17
17
  expect(@bank.timeout).to eq Dinero::Bank::DEFAULT_TIMEOUT
18
18
  end
@@ -20,22 +20,22 @@ if bank_configured? :capital_one_360
20
20
  it "retrieves accounts" do
21
21
  expect(@bank.accounts).to_not be_empty
22
22
  end
23
-
24
- it "extracts account names" do
23
+
24
+ it "extracts account names" do
25
25
  expect(@bank.accounts.map(&:name)).to include /Checking|Savings/
26
26
  end
27
-
28
- it "does not include junk data in the names" do
27
+
28
+ it "does not include junk data in the names" do
29
29
  @bank.accounts.each{|acct| expect(acct.name).to_not include "Opens a new window"}
30
30
  end
31
-
32
- it "extracts account numbers" do
31
+
32
+ it "extracts account numbers" do
33
33
  expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A\d+\Z/}).to_not be_empty
34
34
  end
35
-
36
- it "sets account types" do
35
+
36
+ it "sets account types" do
37
37
  expect(@bank.accounts.map(&:account_type).uniq.sort).to eq account_types
38
38
  end
39
39
  end
40
-
41
- end
40
+
41
+ end
@@ -1,14 +1,14 @@
1
1
  require 'spec_helper'
2
2
 
3
- if bank_configured? :capital_one
3
+ if bank_configured? :capital_one
4
4
 
5
5
  RSpec.describe Dinero::Bank::CapitalOne do
6
6
  let(:bank_configuration) { bank_configurations[:capital_one] }
7
7
  let(:account_types) { bank_configuration[:account_types].sort }
8
8
  let(:accounts) { bank_configuration[:accounts] }
9
-
9
+
10
10
  before(:all) do
11
- VCR.use_cassette("accounts_capital_one") do
11
+ VCR.use_cassette("accounts_capital_one", record: :new_episodes) do
12
12
  @bank = Dinero::Bank::CapitalOne.new(bank_configurations[:capital_one])
13
13
  @bank.accounts
14
14
  end
@@ -17,16 +17,16 @@ if bank_configured? :capital_one
17
17
  it "has expected timeout" do
18
18
  expect(@bank.timeout).to eq Dinero::Bank::CapitalOne::CONNECTION_TIMEOUT
19
19
  end
20
-
21
- it "authenticates" do
20
+
21
+ it "authenticates" do
22
22
  @bank.login!
23
23
  expect(@bank.authenticated?).to eq true
24
24
  end
25
-
25
+
26
26
  it "retrieves accounts_summary_document" do
27
27
  expect(@bank.accounts_summary_document).to be_kind_of Nokogiri::HTML::Document
28
28
  end
29
-
29
+
30
30
  it "has article sections" do
31
31
  expect(@bank.accounts_summary_document.xpath("//article").size).to eq accounts + 1
32
32
  end
@@ -34,18 +34,27 @@ if bank_configured? :capital_one
34
34
  it "gets expected accounts" do
35
35
  expect(@bank.accounts.size).to eq accounts
36
36
  end
37
-
38
- it "extracts account names" do
37
+
38
+ it "extracts account names" do
39
39
  expect(@bank.accounts.map(&:name)).to include /MasterCard|Visa/
40
40
  end
41
-
42
- it "extracts account numbers" do
41
+
42
+ it "extracts account numbers" do
43
43
  expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A[\.|\d]+\Z/}).to_not be_empty
44
44
  end
45
-
46
- it "sets account types" do
45
+
46
+ it "expects balances to be greater than zero" do
47
+ expect(@bank.accounts.map(&:balance).any?(&:zero?)).to eq false
48
+ end
49
+
50
+ it "expects availables to be greater than zero" do
51
+ expect(@bank.accounts.map(&:available).any?(&:zero?)).to eq false
52
+ end
53
+
54
+
55
+ it "sets account types" do
47
56
  expect(@bank.accounts.map(&:account_type).uniq).to eq account_types
48
57
  end
49
58
  end
50
-
51
- end
59
+
60
+ end
@@ -0,0 +1,67 @@
1
+ require 'spec_helper'
2
+
3
+ if bank_configured? :south_state_bank
4
+
5
+ RSpec.describe Dinero::Bank::SouthStateBank do
6
+ let(:bank_configuration) { bank_configurations[:south_state_bank] }
7
+ let(:account_types) { bank_configuration[:account_types].sort }
8
+ let(:accounts) { bank_configuration[:accounts] }
9
+
10
+ before(:all) do
11
+ VCR.use_cassette("accounts_south_state_bank", record: :new_episodes) do
12
+ @bank = Dinero::Bank::SouthStateBank.new(bank_configurations[:south_state_bank])
13
+ @bank.accounts
14
+ end
15
+ end
16
+
17
+ it "has security questions" do
18
+ expect(@bank.security_questions.count).to eq 3
19
+ end
20
+
21
+ it "finds favorite hobby answer" do
22
+ expect(@bank.find_answer("What is your favorite hobby?")).to eq "tennis"
23
+ end
24
+
25
+ it "posts credentials" do
26
+ expect(@bank.authenticated?).to be true
27
+ end
28
+
29
+ it "authenticates" do
30
+ @bank.login!
31
+ expect(@bank.authenticated?).to eq true
32
+ end
33
+
34
+ it "retrieves accounts_summary_document" do
35
+ expect(@bank.accounts_summary_document).to be_kind_of Nokogiri::HTML::Document
36
+ end
37
+
38
+ it "has account line items" do
39
+ expect(@bank.account_table_rows.size).to eq 3
40
+ end
41
+
42
+ it "gets expected accounts" do
43
+ expect(@bank.accounts.size).to eq accounts
44
+ end
45
+
46
+ it "extracts account names" do
47
+ expect(@bank.accounts.map(&:name)).to include "Joint Acct"
48
+ end
49
+
50
+ it "extracts account numbers" do
51
+ expect(@bank.accounts.map(&:number).first).to start_with "******"
52
+ end
53
+
54
+ it "expects balances to be greater than zero" do
55
+ expect(@bank.accounts.map(&:balance).any?(&:zero?)).to eq false
56
+ end
57
+
58
+ it "expects availables to be greater than zero" do
59
+ expect(@bank.accounts.map(&:available).any?(&:zero?)).to eq false
60
+ end
61
+
62
+ it "sets account types" do
63
+ expect(@bank.accounts.map(&:account_type).uniq).to eq account_types
64
+ end
65
+ end
66
+
67
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dinero
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Lang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-05-09 00:00:00.000000000 Z
11
+ date: 2016-11-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -128,14 +128,14 @@ dependencies:
128
128
  requirements:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: 2.45.0
131
+ version: '3.0'
132
132
  type: :runtime
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - "~>"
137
137
  - !ruby/object:Gem::Version
138
- version: 2.45.0
138
+ version: '3.0'
139
139
  - !ruby/object:Gem::Dependency
140
140
  name: nokogiri
141
141
  requirement: !ruby/object:Gem::Requirement
@@ -160,6 +160,7 @@ extensions: []
160
160
  extra_rdoc_files: []
161
161
  files:
162
162
  - ".gitignore"
163
+ - ".rspec"
163
164
  - Gemfile
164
165
  - Gemfile.lock
165
166
  - MIT-LICENSE
@@ -172,10 +173,12 @@ files:
172
173
  - lib/dinero/banks.rb
173
174
  - lib/dinero/banks/capital_one.rb
174
175
  - lib/dinero/banks/capital_one_360.rb
176
+ - lib/dinero/banks/south_state_bank.rb
175
177
  - lib/dinero/version.rb
176
178
  - log/.keep
177
179
  - spec/banks/capital_one_360_spec.rb
178
180
  - spec/banks/capital_one_spec.rb
181
+ - spec/banks/south_state_bank_spec.rb
179
182
  - spec/fixtures/vcr_cassettes/.keep
180
183
  - spec/spec_helper.rb
181
184
  - spec/support/config.rb
@@ -199,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
199
202
  version: '0'
200
203
  requirements: []
201
204
  rubyforge_project:
202
- rubygems_version: 2.4.6
205
+ rubygems_version: 2.5.1
203
206
  signing_key:
204
207
  specification_version: 4
205
208
  summary: Dinero automates the process of logging into banking and financial websites
@@ -207,6 +210,7 @@ summary: Dinero automates the process of logging into banking and financial webs
207
210
  test_files:
208
211
  - spec/banks/capital_one_360_spec.rb
209
212
  - spec/banks/capital_one_spec.rb
213
+ - spec/banks/south_state_bank_spec.rb
210
214
  - spec/fixtures/vcr_cassettes/.keep
211
215
  - spec/spec_helper.rb
212
216
  - spec/support/config.rb