syrup 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -1,6 +1,6 @@
1
- *.swp
2
- **/*.swp
3
- *.gem
4
- .bundle
5
- Gemfile.lock
6
- pkg/*
1
+ *.swp
2
+ **/*.swp
3
+ *.gem
4
+ .bundle
5
+ Gemfile.lock
6
+ pkg/*
data/.rspec CHANGED
@@ -1 +1,2 @@
1
- --color
1
+ --color
2
+ --format documentation
data/CHANGELOG.rdoc CHANGED
@@ -1,3 +1,3 @@
1
- 0.0.1 (Date)
2
-
1
+ 0.0.1 (Date)
2
+
3
3
  * initial release
data/Gemfile CHANGED
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
2
-
3
- # Specify your gem's dependencies in syrup.gemspec
4
- gemspec
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in syrup.gemspec
4
+ gemspec
data/README.rdoc CHANGED
@@ -1,28 +1,46 @@
1
- = Syrup
2
-
3
- Syrup is made from a yummy maple extract.
4
-
5
- == Installation
6
-
7
- The latest version of Syrup can be installed with Rubygems:
8
-
9
- [sudo] gem install "syrup"
10
-
11
- In <b>Rails 3</b>, add this to your Gemfile and run the +bundle+ command.
12
-
13
- gem "syrup"
14
-
15
- In <b>Rails 2</b>, add this to your environment.rb file.
16
-
17
- config.gem "syrup"
18
-
19
- == Getting Started
20
-
21
- Spread it all over your pancakes and enjoy!
22
-
23
- == Usage
24
-
25
- zions = Syrup::Extract.from_institution(:zions_bank)
26
- zions.fetch_accounts.each do |act|
27
- puts name + " " + name.current_balance
28
- end
1
+ = Syrup
2
+
3
+ Syrup helps you to extract bank account information and transactions.
4
+
5
+ == Usage
6
+
7
+ # Setup an instance of the bank
8
+ zions_bank = Syrup.setup_institution('zions_bank') do |config|
9
+ config.username = 'user'
10
+ config.password = 'pass'
11
+ config.secret_questions = {'What is your secret question?' => "I don't know"}
12
+ end
13
+
14
+ # List accounts
15
+ zions_bank.accounts.each do |account|
16
+ puts "#{account.name} (#{account.current_balance}) # => "Checking (100.0)"
17
+ end
18
+
19
+ # Get transactions
20
+ account = zions_bank.find_account_by_id 123456
21
+ transactions = account.find_transactions(Date.today - 30) # => an array of Transactions from the last 30 days
22
+ transactions = account.find_transactions(Date.parse('2011-01-01'), Date.parse('2011-02-01') - 1) # => an array of Transactions from the month of January
23
+
24
+ == Installation
25
+
26
+ The latest version of Syrup can be installed with Rubygems:
27
+
28
+ [sudo] gem install "syrup"
29
+
30
+ In <b>Rails 3</b>, add this to your Gemfile and run the +bundle+ command.
31
+
32
+ gem "syrup"
33
+
34
+ In <b>Rails 2</b>, add this to your environment.rb file.
35
+
36
+ config.gem "syrup"
37
+
38
+ == Supported Institutions
39
+
40
+ Currently, only Zions Bank[http://zionsbank.com] is supported. I will be
41
+ implementing UCCU, USAA, and Wells Fargo (probably in that order). If you would
42
+ like support for a different bank, you have two options:
43
+
44
+ 1. Get me the credentials to log into an account with that bank (you'd have to
45
+ trust me).
46
+ 2. Implement it yourself and submit a pull request.
data/Rakefile CHANGED
@@ -1,11 +1,9 @@
1
- require 'bundler'
2
- require 'rspec/core/rake_task'
3
-
4
- Bundler::GemHelper.install_tasks
5
-
6
- desc "Run RSpec"
7
- RSpec::Core::RakeTask.new do |t|
8
-
9
- end
10
-
1
+ require 'bundler'
2
+ require 'rspec/core/rake_task'
3
+
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ desc "Run tests"
7
+ RSpec::Core::RakeTask.new
8
+
11
9
  task :default => :spec
data/TODO.rdoc ADDED
@@ -0,0 +1,15 @@
1
+
2
+ make sure that mechanize validates SSL certificates
3
+
4
+ pass in username, password, secret questions
5
+
6
+ zions.fetch_accounts
7
+ should I store things?
8
+ List accounts
9
+ * create an array of Account objects
10
+
11
+ account.transactions OR account.fetch_transactions
12
+ zions.
13
+ When getting transactions
14
+ * create an array of Transaction objects
15
+ * populate as many variables on Account as you can (eg. current_balance, etc.)
data/lib/syrup/account.rb CHANGED
@@ -1,5 +1,96 @@
1
- module Syrup
2
- class Account
3
- attr_accessor :name, :id, :account_number, :current_balance, :available_balance, :type
4
- end
1
+ require 'date'
2
+
3
+ module Syrup
4
+ class Account
5
+ # known types are :deposit and :credit
6
+ attr_accessor :id
7
+ attr_writer :name, :type, :account_number, :current_balance, :available_balance, :prior_day_balance
8
+
9
+ def name
10
+ populate
11
+ @name
12
+ end
13
+
14
+ def type
15
+ populate
16
+ @type
17
+ end
18
+
19
+ def account_number
20
+ populate
21
+ @account_number
22
+ end
23
+
24
+ def current_balance
25
+ populate
26
+ @current_balance
27
+ end
28
+
29
+ def available_balance
30
+ populate
31
+ @available_balance
32
+ end
33
+
34
+ def prior_day_balance
35
+ populate
36
+ @prior_day_balance
37
+ end
38
+
39
+ def initialize(attr_hash = nil)
40
+ if attr_hash
41
+ attr_hash.each do |k, v|
42
+ instance_variable_set "@#{k}", v
43
+ end
44
+ end
45
+
46
+ @cached_transactions = []
47
+ end
48
+
49
+ def populated?
50
+ @populated
51
+ end
52
+
53
+ def populated=(value)
54
+ @populated = value
55
+ end
56
+
57
+ def populate
58
+ unless populated? || @institution.nil?
59
+ raise "The account id must not be nil when populating an account" if id.nil?
60
+ @institution.populate_account(id)
61
+ end
62
+ end
63
+
64
+ def ==(other_account)
65
+ other_account.id == self.id && other_account.is_a?(Account)
66
+ end
67
+
68
+ def find_transactions(starting_at, ending_at = Date.today)
69
+ return [] if starting_at > ending_at
70
+
71
+ @institution.fetch_transactions(self.id, starting_at, ending_at)
72
+ end
73
+
74
+ def merge!(account_with_info)
75
+ if account_with_info
76
+ account_with_info.instance_variables.each do |filled_var|
77
+ self.instance_variable_set(filled_var, account_with_info.instance_variable_get(filled_var))
78
+ end
79
+ end
80
+ self
81
+ end
82
+
83
+ def valid?
84
+ if @valid.nil?
85
+ populate
86
+ @valid = populated?
87
+ end
88
+ @valid
89
+ end
90
+
91
+ def valid=(validity)
92
+ @valid = validity
93
+ end
94
+
95
+ end
5
96
  end
@@ -0,0 +1,4 @@
1
+ module Syrup
2
+ class InformationMissingError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,111 @@
1
+ module Syrup
2
+ module Institutions
3
+ class InstitutionBase
4
+
5
+ class << self
6
+ def inherited(subclass)
7
+ @subclasses ||= []
8
+ @subclasses << subclass
9
+ end
10
+
11
+ def subclasses
12
+ @subclasses
13
+ end
14
+ end
15
+
16
+ attr_accessor :username, :password, :secret_questions
17
+
18
+ def initialize
19
+ @accounts = []
20
+ end
21
+
22
+ def setup
23
+ yield self
24
+ self
25
+ end
26
+
27
+ def populated?
28
+ @populated
29
+ end
30
+
31
+ def populated=(value)
32
+ @populated = value
33
+ end
34
+
35
+ def accounts
36
+ populate_accounts
37
+ @accounts
38
+ end
39
+
40
+ def find_account_by_id(account_id)
41
+ account = @accounts.find { |a| a.id == account_id }
42
+ unless account || populated?
43
+ account = Account.new(:id => account_id)
44
+ @accounts << account
45
+ end
46
+ account
47
+ end
48
+
49
+ def populate_account(account_id)
50
+ unless populated?
51
+ result = fetch_account(account_id)
52
+ return nil if result.nil?
53
+
54
+ if result.respond_to?(:each)
55
+ populate_accounts(result)
56
+ find_account_by_id(account_id)
57
+ else
58
+ result.populated = true
59
+ account = find_account_by_id(account_id)
60
+ account.merge! result if account
61
+ end
62
+ end
63
+ end
64
+
65
+ def populate_accounts(populated_accounts = nil)
66
+ unless populated?
67
+ all_accounts = populated_accounts || fetch_accounts
68
+
69
+ # Remove any accounts that were added, that don't actually exist
70
+ @accounts.keep_if do |a|
71
+ if all_accounts.include?(a)
72
+ true
73
+ else
74
+ a.valid = false
75
+ false
76
+ end
77
+ end
78
+
79
+ # Add any additional account information
80
+ new_accounts = []
81
+ all_accounts.each do |filled_account|
82
+ account = @accounts.find { |a| a.id == filled_account.id }
83
+
84
+ filled_account.populated = true
85
+
86
+ # If we already had an account with this id, fill it with data
87
+ if account
88
+ account.merge! filled_account
89
+ else
90
+ new_accounts << filled_account
91
+ end
92
+ end
93
+ @accounts |= new_accounts # Uses set union
94
+
95
+ self.populated = true
96
+ end
97
+ end
98
+
99
+ protected
100
+
101
+ def agent
102
+ @agent ||= Mechanize.new
103
+ end
104
+
105
+ def parse_currency(currency)
106
+ currency.scan(/[0-9.]/).join.to_f
107
+ end
108
+
109
+ end
110
+ end
111
+ end
@@ -1,111 +1,174 @@
1
- module Syrup
2
- module Institutions
3
- class ZionsBank < AbstractInstitution
4
- def self.institution_name
5
- "Zions Bank"
6
- end
7
-
8
- attr_accessor :username, :password, :secret_qas
9
-
10
- def fetch_accounts
11
- ensure_authenticated
12
-
13
- # List accounts
14
- page = agent.get('https://banking.zionsbank.com/ibuir/displayAccountBalance.htm')
15
- json = ActiveSupport::JSON.decode(page.body)
16
-
17
- accounts = []
18
- json['accountBalance']['depositAccountList'].each do |account|
19
- new_account = Account.new
20
- new_account.name = account['name']
21
- new_account.id = account['accountId']
22
- new_account.account_number = account['number']
23
- new_account.current_balance = parse_currency(account['currentAmt'])
24
- new_account.available_balance = parse_currency(account['availableAmt'])
25
- new_account.type = :deposit
26
-
27
- accounts << new_account
28
- end
29
- json['accountBalance']['creditAccountList'].each do |account|
30
- new_account = Account.new
31
- new_account.name = account['name']
32
- new_account.id = account['accountId']
33
- new_account.account_number = account['number']
34
- new_account.current_balance = parse_currency(account['balanceDueAmt'])
35
- new_account.type = :credit
36
-
37
- accounts << new_account
38
- end
39
-
40
- accounts
41
- end
42
-
43
- def fetch_transactions
44
- ensure_authenticated
45
-
46
- # https://banking.zionsbank.com/zfnb/userServlet/app/bank/user/register_view_main?actAcct=498282&sortBy=Default&sortOrder=Default
47
-
48
- # The transactions table is messy. Cells we want either have data, curr, datagrey, or currgrey css class
49
- end
50
-
51
- private
52
-
53
- def ensure_authenticated
54
-
55
- # Check to see if already authenticated
56
- page = agent.get('https://banking.zionsbank.com/ibuir')
57
- if page.body.include?("SessionTimeOutException") # || (page.links.size > 0 && page.links.first.href == "http://www.zionsbank.com")
58
-
59
- raise ArgumentError, "Username must be supplied before authenticating" unless self.username
60
- raise ArgumentError, "Password must be supplied before authenticating" unless self.password
61
-
62
- @agent = Mechanize.new
63
-
64
- # Enter the username
65
- page = agent.get('https://zionsbank.com')
66
- form = page.form('logonForm')
67
- form.publicCred1 = username
68
- page = form.submit
69
-
70
- # If the supplied username is incorrect, raise an exception
71
- raise "Invalid username" if page.title == "Error Page"
72
-
73
- # Go on to the next page
74
- page = page.links.first.click
75
-
76
- # Find the secret question
77
- question = page.search('div.form_field')[2].css('div').inner_text
78
-
79
- # If the answer to this question was not supplied, raise an exception
80
- raise question unless secret_qas[question]
81
-
82
- # Enter the answer to the secret question
83
- form = page.forms.first
84
- form["challengeEntry.answerText"] = secret_qas[question]
85
- form.radiobutton_with(:value => 'false').check
86
- submit_button = form.button_with(:name => '_eventId_submit')
87
- page = form.submit(submit_button)
88
-
89
- # If the supplied answer is incorrect, raise an exception
90
- raise "Invalid answer" unless page.search('#errorComponent').empty?
91
-
92
- # Enter the password
93
- form = page.forms.first
94
- form.privateCred1 = password
95
- submit_button = form.button_with(:name => '_eventId_submit')
96
- page = form.submit(submit_button)
97
-
98
- # If the supplied password is incorrect, raise an exception
99
- raise "Invalid password" unless page.search('#errorComponent').empty?
100
-
101
- # Clicking this link logs us into the banking.zionsbank.com domain
102
- page = page.links.first.click
103
-
104
- end
105
-
106
- true
107
- end
108
-
109
- end
110
- end
1
+ require 'date'
2
+
3
+ module Syrup
4
+ module Institutions
5
+ class ZionsBank < InstitutionBase
6
+
7
+ class << self
8
+ def name
9
+ "Zions Bank"
10
+ end
11
+
12
+ def id
13
+ "zions_bank"
14
+ end
15
+ end
16
+
17
+ def fetch_account(account_id)
18
+ fetch_accounts
19
+ end
20
+
21
+ def fetch_accounts
22
+ ensure_authenticated
23
+
24
+ # List accounts
25
+ page = agent.get('https://banking.zionsbank.com/ibuir/displayAccountBalance.htm')
26
+ json = ActiveSupport::JSON.decode(page.body)
27
+
28
+ accounts = []
29
+ json['accountBalance']['depositAccountList'].each do |account|
30
+ new_account = Account.new(:id => account['accountId'])
31
+ new_account.name = account['name']
32
+ new_account.account_number = account['number']
33
+ new_account.current_balance = parse_currency(account['currentAmt'])
34
+ new_account.available_balance = parse_currency(account['availableAmt'])
35
+ new_account.type = :deposit
36
+
37
+ accounts << new_account
38
+ end
39
+ json['accountBalance']['creditAccountList'].each do |account|
40
+ new_account = Account.new(:id => account['accountId'])
41
+ new_account.name = account['name']
42
+ new_account.account_number = account['number']
43
+ new_account.current_balance = parse_currency(account['balanceDueAmt'])
44
+ new_account.type = :credit
45
+
46
+ accounts << new_account
47
+ end
48
+
49
+ accounts
50
+ end
51
+
52
+ def fetch_transactions(account_id, starting_at, ending_at)
53
+ ensure_authenticated
54
+
55
+ transactions = []
56
+
57
+ post_vars = { "actAcct" => account_id, "dayRange.searchType" => "dates", "dayRange.startDate" => starting_at.strftime('%m/%d/%Y'), "dayRange.endDate" => ending_at.strftime('%m/%d/%Y'), "submit_view.x" => 11, "submit_view.y" => 11, "submit_view" => "view" }
58
+
59
+ page = agent.post("https://banking.zionsbank.com/zfnb/userServlet/app/bank/user/register_view_main?reSort=false&actAcct=#{account_id}", post_vars)
60
+
61
+ page.search('tr').each do |row_element|
62
+ data = []
63
+ datapart = row_element.css('.data')
64
+ if datapart
65
+ data += datapart
66
+ datapart = row_element.css('.curr')
67
+ data += datapart if datapart
68
+ end
69
+
70
+ datapart = row_element.css('.datagrey')
71
+ if datapart
72
+ data += datapart
73
+ datapart = row_element.css('.currgrey')
74
+ data += datapart if datapart
75
+ end
76
+
77
+ if data.size == 7
78
+ data.map! {|cell| cell.inner_html.strip.gsub(/[^ -~]/, '') }
79
+
80
+ transaction = Transaction.new
81
+
82
+ transaction.posted_at = Date.strptime(data[0], '%m/%d/%Y')
83
+ transaction.payee = data[2]
84
+ transaction.status = data[3].include?("Posted") ? :posted : :pending
85
+ unless data[4].empty?
86
+ transaction.amount = -parse_currency(data[4])
87
+ end
88
+ unless data[5].empty?
89
+ transaction.amount = parse_currency(data[5])
90
+ end
91
+
92
+ transactions << transaction
93
+ end
94
+ end
95
+
96
+ # https://banking.zionsbank.com/zfnb/userServlet/app/bank/user/register_view_main?actAcct=498282&sortBy=Default&sortOrder=Default
97
+ # actAcct is the accountId (498282)
98
+ # dayRange.startDate, dayRange.endDate
99
+ # dayRange.searchType (dates or days, dates uses dayRange.startDate and dayRange.endDate, days uses dayRange.numberOfDays)
100
+
101
+ # The transactions table is messy. Cells we want either have data, curr, datagrey, or currgrey css class
102
+ # 1. The date (initiated or cleared? they're generally the same)
103
+ # 2. The type of transaction (Debit, Transfer Debit, ATM Debit, Deposit 3785596). This may be irrelevant because of the position of the transaction amount.)
104
+ # 3. The payee
105
+ # 4. The transaction status (Posted or ... Pending?)
106
+ # 5. The debit amount
107
+ # 6. The deposit amount
108
+ # 7. The then-current account balance
109
+
110
+ transactions
111
+ end
112
+
113
+ private
114
+
115
+ def ensure_authenticated
116
+
117
+ # Check to see if already authenticated
118
+ page = agent.get('https://banking.zionsbank.com/ibuir')
119
+ if page.body.include?("SessionTimeOutException")
120
+
121
+ raise InformationMissingError, "Please supply a username" unless self.username
122
+ raise InformationMissingError, "Please supply a password" unless self.password
123
+
124
+ @agent = Mechanize.new
125
+
126
+ # Enter the username
127
+ page = agent.get('https://zionsbank.com')
128
+ form = page.form('logonForm')
129
+ form.publicCred1 = username
130
+ page = form.submit
131
+
132
+ # If the supplied username is incorrect, raise an exception
133
+ raise "Invalid username" if page.title == "Error Page"
134
+
135
+ # Go on to the next page
136
+ page = page.links.first.click
137
+
138
+ # Find the secret question
139
+ question = page.search('div.form_field')[2].css('div').inner_text
140
+
141
+ # If the answer to this question was not supplied, raise an exception
142
+ raise InformationMissingError, "Please answer the question, \"#{question}\"" unless secret_questions[question]
143
+
144
+ # Enter the answer to the secret question
145
+ form = page.forms.first
146
+ form["challengeEntry.answerText"] = secret_questions[question]
147
+ form.radiobutton_with(:value => 'false').check
148
+ submit_button = form.button_with(:name => '_eventId_submit')
149
+ page = form.submit(submit_button)
150
+
151
+ # If the supplied answer is incorrect, raise an exception
152
+ raise InformationMissingError, "\"#{secret_questions[question]}\" is not the correct answer to, \"#{question}\"" unless page.search('#errorComponent').empty?
153
+
154
+ # Enter the password
155
+ form = page.forms.first
156
+ form.privateCred1 = password
157
+ submit_button = form.button_with(:name => '_eventId_submit')
158
+ page = form.submit(submit_button)
159
+
160
+ # If the supplied password is incorrect, raise an exception
161
+ raise InformationMissingError, "An invalid password was supplied" unless page.search('#errorComponent').empty?
162
+
163
+ # Clicking this link logs us into the banking.zionsbank.com domain
164
+ page = page.links.first.click
165
+
166
+ raise "Unknown URL reached. Try logging in manually through a browser." if page.uri.to_s != "https://banking.zionsbank.com/ibuir/displayUserInterface.htm"
167
+ end
168
+
169
+ true
170
+ end
171
+
172
+ end
173
+ end
111
174
  end