syrup 0.0.1 → 0.0.2

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.
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