syrup 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +8 -7
- data/.rspec +2 -2
- data/CHANGELOG.rdoc +8 -2
- data/Gemfile +4 -4
- data/README.rdoc +46 -46
- data/Rakefile +8 -8
- data/TODO.rdoc +12 -12
- data/lib/syrup/account.rb +142 -143
- data/lib/syrup/information_missing_error.rb +8 -8
- data/lib/syrup/institutions/institution_base.rb +142 -142
- data/lib/syrup/institutions/uccu.rb +135 -0
- data/lib/syrup/institutions/zions_bank.rb +175 -175
- data/lib/syrup/transaction.rb +19 -19
- data/lib/syrup/version.rb +3 -3
- data/lib/syrup.rb +46 -46
- data/spec/spec_helper.rb +15 -15
- data/spec/syrup/account_spec.rb +52 -52
- data/spec/syrup/institutions/institution_base_spec.rb +119 -119
- data/spec/syrup/institutions/uccu_spec.rb +45 -0
- data/spec/syrup/institutions/zions_bank_spec.rb +40 -40
- data/spec/syrup/syrup_spec.rb +40 -40
- data/spec/syrup/transaction_spec.rb +20 -20
- data/syrup.gemspec +26 -26
- metadata +60 -42
@@ -1,149 +1,149 @@
|
|
1
|
-
module Syrup
|
2
|
-
module Institutions
|
3
|
-
class InstitutionBase
|
4
|
-
|
5
|
-
class << self
|
6
|
-
# This method is called whenever a class inherits from this class. We keep track of
|
7
|
-
# all of them because they should all be institutions. This way we can provide a
|
8
|
-
# list of supported institutions via code.
|
9
|
-
def inherited(subclass)
|
10
|
-
@subclasses ||= []
|
11
|
-
@subclasses << subclass
|
12
|
-
end
|
13
|
-
|
14
|
-
# Returns an array of all classes that inherit from this class. Or, in other words,
|
15
|
-
# an array of all supported institutions
|
16
|
-
def subclasses
|
17
|
-
@subclasses
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
##
|
22
|
-
# :attr_writer: populated
|
23
|
-
|
24
|
-
##
|
25
|
-
# :attr_reader: populated?
|
26
|
-
|
27
|
-
##
|
28
|
-
# :attr_reader: agent
|
29
|
-
# Gets an instance of Mechanize for use by any subclasses.
|
30
|
-
|
31
|
-
##
|
32
|
-
# :attr_reader: accounts
|
33
|
-
# Returns an array of all of the user's accounts at this institution.
|
34
|
-
# If accounts hasn't been populated, it populates accounts and then returns them.
|
35
|
-
|
36
|
-
#
|
37
|
-
attr_accessor :username, :password, :secret_questions
|
38
|
-
|
39
|
-
def initialize
|
40
|
-
@accounts = []
|
41
|
-
end
|
42
|
-
|
43
|
-
# This method allows you to setup an institution with block syntax
|
44
|
-
#
|
45
|
-
# InstitutionBase.setup do |config|
|
46
|
-
# config.username = 'my_user"
|
47
|
-
# ...
|
48
|
-
# end
|
49
|
-
def setup
|
50
|
-
yield self
|
51
|
-
self
|
52
|
-
end
|
53
|
-
|
54
|
-
def populated?
|
55
|
-
@populated
|
56
|
-
end
|
57
|
-
|
58
|
-
def populated=(value)
|
59
|
-
@populated = value
|
60
|
-
end
|
61
|
-
|
62
|
-
def accounts
|
63
|
-
populate_accounts
|
64
|
-
@accounts
|
65
|
-
end
|
1
|
+
module Syrup
|
2
|
+
module Institutions
|
3
|
+
class InstitutionBase
|
4
|
+
|
5
|
+
class << self
|
6
|
+
# This method is called whenever a class inherits from this class. We keep track of
|
7
|
+
# all of them because they should all be institutions. This way we can provide a
|
8
|
+
# list of supported institutions via code.
|
9
|
+
def inherited(subclass)
|
10
|
+
@subclasses ||= []
|
11
|
+
@subclasses << subclass
|
12
|
+
end
|
13
|
+
|
14
|
+
# Returns an array of all classes that inherit from this class. Or, in other words,
|
15
|
+
# an array of all supported institutions
|
16
|
+
def subclasses
|
17
|
+
@subclasses
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
##
|
22
|
+
# :attr_writer: populated
|
23
|
+
|
24
|
+
##
|
25
|
+
# :attr_reader: populated?
|
26
|
+
|
27
|
+
##
|
28
|
+
# :attr_reader: agent
|
29
|
+
# Gets an instance of Mechanize for use by any subclasses.
|
30
|
+
|
31
|
+
##
|
32
|
+
# :attr_reader: accounts
|
33
|
+
# Returns an array of all of the user's accounts at this institution.
|
34
|
+
# If accounts hasn't been populated, it populates accounts and then returns them.
|
35
|
+
|
36
|
+
#
|
37
|
+
attr_accessor :username, :password, :secret_questions
|
38
|
+
|
39
|
+
def initialize
|
40
|
+
@accounts = []
|
41
|
+
end
|
42
|
+
|
43
|
+
# This method allows you to setup an institution with block syntax
|
44
|
+
#
|
45
|
+
# InstitutionBase.setup do |config|
|
46
|
+
# config.username = 'my_user"
|
47
|
+
# ...
|
48
|
+
# end
|
49
|
+
def setup
|
50
|
+
yield self
|
51
|
+
self
|
52
|
+
end
|
53
|
+
|
54
|
+
def populated?
|
55
|
+
@populated
|
56
|
+
end
|
57
|
+
|
58
|
+
def populated=(value)
|
59
|
+
@populated = value
|
60
|
+
end
|
61
|
+
|
62
|
+
def accounts
|
63
|
+
populate_accounts
|
64
|
+
@accounts
|
65
|
+
end
|
66
66
|
|
67
67
|
# Returns an account with the specified +account_id+. Always use this method to
|
68
68
|
# create a new `Account` object. If you do, it will get populated correctly whenever
|
69
|
-
# the population occurs.
|
70
|
-
def find_account_by_id(account_id)
|
71
|
-
account = @accounts.find { |a| a.id == account_id }
|
72
|
-
unless account || populated?
|
73
|
-
account = Account.new(:id => account_id)
|
74
|
-
@accounts << account
|
75
|
-
end
|
76
|
-
account
|
77
|
-
end
|
69
|
+
# the population occurs.
|
70
|
+
def find_account_by_id(account_id)
|
71
|
+
account = @accounts.find { |a| a.id == account_id }
|
72
|
+
unless account || populated?
|
73
|
+
account = Account.new(:id => account_id, :institution => self)
|
74
|
+
@accounts << account
|
75
|
+
end
|
76
|
+
account
|
77
|
+
end
|
78
78
|
|
79
79
|
# Populates an account given an `account_id`. The implementing institution may populate
|
80
80
|
# all accounts when this is called if there isn't a way to only request one account's
|
81
|
-
# information.
|
82
|
-
def populate_account(account_id)
|
83
|
-
unless populated?
|
84
|
-
result = fetch_account(account_id)
|
85
|
-
return nil if result.nil?
|
86
|
-
|
87
|
-
if result.respond_to?(:each)
|
88
|
-
populate_accounts(result)
|
89
|
-
find_account_by_id(account_id)
|
90
|
-
else
|
91
|
-
result.populated = true
|
92
|
-
account = find_account_by_id(account_id)
|
93
|
-
account.merge! result if account
|
94
|
-
end
|
95
|
-
end
|
96
|
-
end
|
97
|
-
|
98
|
-
# Populates all of the user's accounts at this institution.
|
99
|
-
def populate_accounts(populated_accounts = nil)
|
100
|
-
unless populated?
|
101
|
-
all_accounts = populated_accounts || fetch_accounts
|
102
|
-
|
103
|
-
# Remove any accounts that were added, that don't actually exist
|
104
|
-
@accounts.keep_if do |a|
|
105
|
-
if all_accounts.include?(a)
|
106
|
-
true
|
107
|
-
else
|
108
|
-
a.valid = false
|
109
|
-
false
|
110
|
-
end
|
111
|
-
end
|
112
|
-
|
113
|
-
# Add any additional account information
|
114
|
-
new_accounts = []
|
115
|
-
all_accounts.each do |filled_account|
|
116
|
-
account = @accounts.find { |a| a.id == filled_account.id }
|
117
|
-
|
118
|
-
filled_account.populated = true
|
119
|
-
|
120
|
-
# If we already had an account with this id, fill it with data
|
121
|
-
if account
|
122
|
-
account.merge! filled_account
|
123
|
-
else
|
124
|
-
new_accounts << filled_account
|
125
|
-
end
|
126
|
-
end
|
127
|
-
@accounts |= new_accounts # Uses set union
|
128
|
-
|
129
|
-
self.populated = true
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
protected
|
134
|
-
|
135
|
-
def agent
|
136
|
-
@agent ||= Mechanize.new
|
137
|
-
end
|
138
|
-
|
139
|
-
# This is just a helper method that simplifies the common process of extracting a number
|
140
|
-
# from a string representing a currency.
|
141
|
-
#
|
142
|
-
# parse_currency('$ 1,234.56') #=> 1234.56
|
143
|
-
def parse_currency(currency)
|
144
|
-
currency.scan(/[0-9.]/).join.to_f
|
145
|
-
end
|
146
|
-
|
147
|
-
end
|
148
|
-
end
|
81
|
+
# information.
|
82
|
+
def populate_account(account_id)
|
83
|
+
unless populated?
|
84
|
+
result = fetch_account(account_id)
|
85
|
+
return nil if result.nil?
|
86
|
+
|
87
|
+
if result.respond_to?(:each)
|
88
|
+
populate_accounts(result)
|
89
|
+
find_account_by_id(account_id)
|
90
|
+
else
|
91
|
+
result.populated = true
|
92
|
+
account = find_account_by_id(account_id)
|
93
|
+
account.merge! result if account
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Populates all of the user's accounts at this institution.
|
99
|
+
def populate_accounts(populated_accounts = nil)
|
100
|
+
unless populated?
|
101
|
+
all_accounts = populated_accounts || fetch_accounts
|
102
|
+
|
103
|
+
# Remove any accounts that were added, that don't actually exist
|
104
|
+
@accounts.keep_if do |a|
|
105
|
+
if all_accounts.include?(a)
|
106
|
+
true
|
107
|
+
else
|
108
|
+
a.valid = false
|
109
|
+
false
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Add any additional account information
|
114
|
+
new_accounts = []
|
115
|
+
all_accounts.each do |filled_account|
|
116
|
+
account = @accounts.find { |a| a.id == filled_account.id }
|
117
|
+
|
118
|
+
filled_account.populated = true
|
119
|
+
|
120
|
+
# If we already had an account with this id, fill it with data
|
121
|
+
if account
|
122
|
+
account.merge! filled_account
|
123
|
+
else
|
124
|
+
new_accounts << filled_account
|
125
|
+
end
|
126
|
+
end
|
127
|
+
@accounts |= new_accounts # Uses set union
|
128
|
+
|
129
|
+
self.populated = true
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
protected
|
134
|
+
|
135
|
+
def agent
|
136
|
+
@agent ||= Mechanize.new
|
137
|
+
end
|
138
|
+
|
139
|
+
# This is just a helper method that simplifies the common process of extracting a number
|
140
|
+
# from a string representing a currency.
|
141
|
+
#
|
142
|
+
# parse_currency('$ 1,234.56') #=> 1234.56
|
143
|
+
def parse_currency(currency)
|
144
|
+
currency.scan(/[0-9.]/).join.to_f
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
end
|
149
149
|
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Syrup
|
4
|
+
module Institutions
|
5
|
+
class Uccu < InstitutionBase
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def name
|
9
|
+
"UCCU"
|
10
|
+
end
|
11
|
+
|
12
|
+
def id
|
13
|
+
"uccu"
|
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.post('https://pb.uccu.com/UCCU/Ajax/RpcHandler.ashx',
|
26
|
+
'{"id":0,"method":"accounts.getBalances","params":[false]}',
|
27
|
+
'X-JSON-RPC' => 'accounts.getBalances')
|
28
|
+
|
29
|
+
json = MultiJson.decode(page.body)
|
30
|
+
|
31
|
+
accounts = []
|
32
|
+
json['result'].each do |account|
|
33
|
+
next if account['accountIndex'] == -1
|
34
|
+
|
35
|
+
new_account = Account.new(:id => account['accountIndex'], :institution => self)
|
36
|
+
new_account.name = account['displayName'][/^[^(]*/, 0].strip
|
37
|
+
new_account.account_number = account['displayName'][/\(([*0-9-]+)\)/, 1]
|
38
|
+
new_account.current_balance = account['current'].to_f
|
39
|
+
new_account.available_balance = account['available'].to_f
|
40
|
+
# new_account.type = :deposit # :credit
|
41
|
+
|
42
|
+
accounts << new_account
|
43
|
+
end
|
44
|
+
|
45
|
+
accounts
|
46
|
+
end
|
47
|
+
|
48
|
+
def fetch_transactions(account_id, starting_at, ending_at)
|
49
|
+
ensure_authenticated
|
50
|
+
|
51
|
+
transactions = []
|
52
|
+
|
53
|
+
page = agent.get("https://pb.uccu.com/UCCU/Accounts/Activity.aspx?index=#{account_id}")
|
54
|
+
form = page.form("MAINFORM")
|
55
|
+
form.ddlAccounts = account_id
|
56
|
+
form.ddlType = 0 # 0 = All types of transactions
|
57
|
+
form.field_with(:id => 'txtFromDate_textBox').value = starting_at.month.to_s + starting_at.strftime('/%e/%Y')
|
58
|
+
form.field_with(:id => 'txtToDate_textBox').value = ending_at.month.to_s + ending_at.strftime('/%e/%Y')
|
59
|
+
submit_button = form.button_with(:name => 'btnSubmitHistoryRequest')
|
60
|
+
page = form.submit(submit_button)
|
61
|
+
|
62
|
+
# Look for the account balance
|
63
|
+
account = find_account_by_id(account_id)
|
64
|
+
page.search('.summaryTable tr').each do |row_element|
|
65
|
+
first_cell_text = ''
|
66
|
+
row_element.children.each do |cell_element|
|
67
|
+
if first_cell_text.empty?
|
68
|
+
first_cell_text = cell_element.content.strip if cell_element.respond_to? :name
|
69
|
+
else
|
70
|
+
case first_cell_text
|
71
|
+
when "Available Balance:"
|
72
|
+
account.available_balance = parse_currency(cell_element.content.strip)
|
73
|
+
when "Current Balance:"
|
74
|
+
account.current_balance = parse_currency(cell_element.content.strip)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Get all the transactions
|
81
|
+
page.search('#ctlAccountActivityChecking tr').each do |row_element|
|
82
|
+
next if row_element['class'] == 'header'
|
83
|
+
|
84
|
+
data = row_element.css('td').map {|element| element.content.strip }
|
85
|
+
|
86
|
+
transaction = Transaction.new
|
87
|
+
transaction.posted_at = Date.strptime(data[0], '%m/%d/%Y')
|
88
|
+
transaction.payee = data[3]
|
89
|
+
transaction.status = :posted # :pending
|
90
|
+
transaction.amount = -parse_currency(data[4]) if data[4].size > 1
|
91
|
+
transaction.amount = parse_currency(data[5]) if data[5].size > 1
|
92
|
+
|
93
|
+
transactions << transaction
|
94
|
+
end
|
95
|
+
|
96
|
+
transactions
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def ensure_authenticated
|
102
|
+
|
103
|
+
# Check to see if already authenticated
|
104
|
+
page = agent.get('https://pb.uccu.com/UCCU/Accounts/Activity.aspx')
|
105
|
+
if page.body.include?("Please enter your User ID and Password below.") || page.body.include?("Your Online Banking session has expired.")
|
106
|
+
|
107
|
+
raise InformationMissingError, "Please supply a username" unless self.username
|
108
|
+
raise InformationMissingError, "Please supply a password" unless self.password
|
109
|
+
|
110
|
+
@agent = Mechanize.new
|
111
|
+
|
112
|
+
# Enter the username
|
113
|
+
page = agent.get('https://pb.uccu.com/UCCU/Login.aspx')
|
114
|
+
form = page.form('MAINFORM')
|
115
|
+
form.field_with(:id => 'ctlSignon_txtUserID').value = username
|
116
|
+
form.field_with(:id => 'ctlSignon_txtPassword').value = password
|
117
|
+
form.field_with(:id => 'ctlSignon_ddlSignonDestination').value = 'Accounts.Overview'
|
118
|
+
form.TestJavaScript = 'OK'
|
119
|
+
login_button = form.button_with(:name => 'ctlSignon:btnLogin')
|
120
|
+
page = form.submit(login_button)
|
121
|
+
|
122
|
+
# If the supplied username/password is incorrect, raise an exception
|
123
|
+
raise InformationMissingError, "Invalid username or password" if page.body.include?("Login not accepted.") || page.body.include?("Please enter a valid signon ID.") || page.body.include?("Please enter a valid Password.")
|
124
|
+
|
125
|
+
# Secret questions???
|
126
|
+
|
127
|
+
raise "Unknown URL reached. Try logging in manually through a browser." if page.uri.to_s != "https://pb.uccu.com/UCCU/Accounts/Overview.aspx"
|
128
|
+
end
|
129
|
+
|
130
|
+
true
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|