bankjob 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,355 @@
1
+ require 'rubygems'
2
+ require 'builder'
3
+ require 'fastercsv'
4
+ require 'bankjob'
5
+
6
+ module Bankjob
7
+
8
+ ##
9
+ # A Statement object represents a bank statement and is generally the result of running a Bankjob scraper.
10
+ # The Statement holds an array of Transaction objects and specifies the closing balance and the currency in use.
11
+ #
12
+ # A Scraper will create a Statement by scraping web pages in an online banking site.
13
+ # The Statement can then be stored as a file in CSV (Comma Separated Values) format
14
+ # using +to_csv+ or in OFX (Open Financial eXchange http://www.ofx.net) format
15
+ # using +to_ofx+.
16
+ #
17
+ # One special ability of Statement is the ability to merge with an existing statement,
18
+ # automatically eliminating overlapping transactions.
19
+ # This means that when writing subsequent statements to the same CSV file
20
+ # <em>(note well: CSV only)</em> a continous transaction record can be built up
21
+ # over a long period.
22
+ #
23
+ class Statement
24
+
25
+ # OFX value for the ACCTTYPE of a checking account
26
+ CHECKING = "CHECKING"
27
+
28
+ # OFX value for the ACCTTYPE of a savings account
29
+ SAVINGS = "SAVINGS"
30
+
31
+ # OFX value for the ACCTTYPE of a money market account
32
+ MONEYMRKT = "MONEYMRKT"
33
+
34
+ # OFX value for the ACCTTYPE of a loan account
35
+ CREDITLINE = "CREDITLINE"
36
+
37
+ # the account balance after the last transaction in the statement
38
+ # Translates to the OFX element BALAMT in LEDGERBAL
39
+ attr_accessor :closing_balance
40
+
41
+ # the avaliable funds in the account after the last transaction in the statement (generally the same as closing_balance)
42
+ # Translates to the OFX element BALAMT in AVAILBAL
43
+ attr_accessor :closing_available
44
+
45
+ # the array of Transaction objects that comprise the statement
46
+ attr_accessor :transactions
47
+
48
+ # the three-letter currency symbol generated into the OFX output (defaults to EUR)
49
+ # This is passed into the initializer (usually by the Scraper - see Scraper#currency)
50
+ attr_reader :currency
51
+
52
+ # the identifier of the bank - a 1-9 char string (may be empty)
53
+ # Translates to the OFX element BANKID
54
+ attr_accessor :bank_id
55
+
56
+ # the account number of the statement - a 1-22 char string that must be passed
57
+ # into the initalizer of the Statement
58
+ # Translates to the OFX element ACCTID
59
+ attr_accessor :account_number
60
+
61
+ # the type of bank account the statement is for
62
+ # Tranlsates to the OFX type ACCTTYPE and must be one of
63
+ # * CHECKING
64
+ # * SAVINGS
65
+ # * MONEYMRKT
66
+ # * CREDITLINE
67
+ # Use a constant to set this - defaults to CHECKING
68
+ attr_accessor :account_type
69
+
70
+ ##
71
+ # Creates a new empty Statement with no transactions.
72
+ # The +account_number+ must be specified as a 1-22 character string.
73
+ # The specified +currency+ defaults to EUR if nothing is passed in.
74
+ #
75
+ def initialize(account_number, currency = "EUR")
76
+ @account_number = account_number
77
+ @currency = currency
78
+ @transactions = []
79
+ @account_type = CHECKING
80
+ end
81
+
82
+ ##
83
+ # Appends a new Transaction to the end of this Statement
84
+ #
85
+ def add_transaction(transaction)
86
+ @transactions << transaction
87
+ end
88
+
89
+ ##
90
+ # Overrides == to allow comparison of Statement objects.
91
+ # Two Statements are considered equal (that is, ==) if
92
+ # and only iff they have the same values for:
93
+ # * +to_date+
94
+ # * +from_date+
95
+ # * +closing_balance+
96
+ # * +closing_available+
97
+ # * each and every transaction.
98
+ # Note that the transactions are compared with Transaction.==
99
+ #
100
+ def ==(other) # :nodoc:
101
+ if other.kind_of?(Statement)
102
+ return (from_date == other.from_date and
103
+ to_date == other.to_date and
104
+ closing_balance == other.closing_balance and
105
+ closing_available == other.closing_available and
106
+ transactions == other.transactions)
107
+ end
108
+ return false
109
+ end
110
+
111
+ ##
112
+ # Merges the transactions of +other+ into the transactions of this statement
113
+ # and returns the resulting array of transactions
114
+ # Raises an exception if the two statements overlap in a discontiguous fashion.
115
+ #
116
+ def merge_transactions(other)
117
+ if (other.kind_of?(Statement))
118
+ union = transactions | other.transactions # the set union of both
119
+ # now check that the union contains all of the originals, otherwise
120
+ # we have merged some sort of non-contiguous range
121
+ raise "Failed to merge transactions properly." unless union.first(@transactions.length) == @transactions
122
+ return union
123
+ end
124
+ end
125
+
126
+ ##
127
+ # Merges the transactions of +other+ into the transactions of this statement
128
+ # and returns the result.
129
+ # Neither statement is changed. See #merge! if you want to modify the statement.
130
+ # Raises an exception if the two statements overlap in a discontiguous fashion.
131
+ #
132
+ def merge(other)
133
+ union = merge_transactions(other)
134
+ merged = self.dup
135
+ merged.closing_balance = nil
136
+ merged.closing_available = nil
137
+ merged.transactions = union
138
+ return merged
139
+ end
140
+
141
+ ##
142
+ # Merges the transactions of +other+ into the transactions of this statement.
143
+ # Causes this statement to be changed. See #merge for details.
144
+ #
145
+ def merge!(other)
146
+ @closing_balance = nil
147
+ @closing_available = nil
148
+ @transactions = merge_transactions(other)
149
+ end
150
+
151
+ ##
152
+ # Returns the statement's start date.
153
+ # The +from_date+ is taken from the date of the last transaction in the statement
154
+ #
155
+ def from_date()
156
+ return nil if @transactions.empty?
157
+ @transactions.last.date
158
+ end
159
+
160
+ ##
161
+ # Returns the statement's end date.
162
+ # The +to_date+ is taken from the date of the first transaction in the statement
163
+ #
164
+ def to_date()
165
+ return nil if @transactions.empty?
166
+ @transactions.first.date
167
+ end
168
+
169
+ ##
170
+ # Returns the closing balance by looking at the
171
+ # new balance of the first transaction.
172
+ # If there are no transactions, +nil+ is returned.
173
+ #
174
+ def closing_balance()
175
+ return nil if @closing_balance.nil? and @transactions.empty?
176
+ @closing_balance ||= @transactions.first.new_balance
177
+ end
178
+
179
+ ##
180
+ # Returns the closing available balance by looking at the
181
+ # new balance of the first transaction.
182
+ # If there are no transactions, +nil+ is returned.
183
+ # Note that this is the same value returned as +closing_balance+.
184
+ #
185
+ def closing_available()
186
+ return nil if @closing_available.nil? and @transactions.empty?
187
+ @closing_available ||= @transactions.first.new_balance
188
+ end
189
+
190
+ ##
191
+ # Generates a CSV (comma separated values) string with a single
192
+ # row for each transaction.
193
+ # Note that no header row is generated as it would make it
194
+ # difficult to concatenate and merge subsequent CSV strings
195
+ # (but we should consider it as a user option in the future)
196
+ #
197
+ def to_csv
198
+ buf = ""
199
+ transactions.each do |transaction|
200
+ buf << transaction.to_csv
201
+ end
202
+ return buf
203
+ end
204
+
205
+ ##
206
+ # Generates a string for use as a header in a CSV file for a statement.
207
+ #
208
+ # Delegates to Transaction#csv_header
209
+ #
210
+ def self.csv_header
211
+ return Transaction.csv_header
212
+ end
213
+
214
+ ##
215
+ # Reads in transactions from a CSV file or string specified by +source+
216
+ # and adds them to this statement.
217
+ #
218
+ # Uses a simple (dumb) heuristic to determine if the +source+ is a file
219
+ # or a string: if it contains a comma (,) then it is a string
220
+ # otherwise it is treated as a file path.
221
+ #
222
+ def from_csv(source, decimal = ".")
223
+ if (source =~ /,/)
224
+ # assume source is a string
225
+ FasterCSV.parse(source) do |row|
226
+ add_transaction(Transaction.from_csv(row, decimal))
227
+ end
228
+ else
229
+ # assume source is a filepath
230
+ FasterCSV.foreach(source) do |row|
231
+ add_transaction(Transaction.from_csv(row, decimal))
232
+ end
233
+ end
234
+ end
235
+
236
+ ##
237
+ # Generates an XML string adhering to the OFX standard
238
+ # (see Open Financial eXchange http://www.ofx.net)
239
+ # representing a single bank statement holding a list
240
+ # of transactions.
241
+ # The XML for the individual transactions is generated
242
+ # by the Transaction class itself.
243
+ #
244
+ # The OFX 2 schema for a statement response (STMTRS) is:
245
+ #
246
+ # <xsd:complexType name="StatementResponse">
247
+ # <xsd:annotation>
248
+ # <xsd:documentation>
249
+ # The OFX element "STMTRS" is of type "StatementResponse"
250
+ # </xsd:documentation>
251
+ # </xsd:annotation>
252
+ #
253
+ # <xsd:sequence>
254
+ # <xsd:element name="CURDEF" type="ofx:CurrencyEnum"/>
255
+ # <xsd:element name="BANKACCTFROM" type="ofx:BankAccount"/>
256
+ # <xsd:element name="BANKTRANLIST" type="ofx:BankTransactionList" minOccurs="0"/>
257
+ # <xsd:element name="LEDGERBAL" type="ofx:LedgerBalance"/>
258
+ # <xsd:element name="AVAILBAL" type="ofx:AvailableBalance" minOccurs="0"/>
259
+ # <xsd:element name="BALLIST" type="ofx:BalanceList" minOccurs="0"/>
260
+ # <xsd:element name="MKTGINFO" type="ofx:InfoType" minOccurs="0"/>
261
+ # </xsd:sequence>
262
+ # </xsd:complexType>
263
+ #
264
+ # Where the BANKTRANLIST (Bank Transaction List) is defined as:
265
+ #
266
+ # <xsd:complexType name="BankTransactionList">
267
+ # <xsd:annotation>
268
+ # <xsd:documentation>
269
+ # The OFX element "BANKTRANLIST" is of type "BankTransactionList"
270
+ # </xsd:documentation>
271
+ # </xsd:annotation>
272
+ # <xsd:sequence>
273
+ # <xsd:element name="DTSTART" type="ofx:DateTimeType"/>
274
+ # <xsd:element name="DTEND" type="ofx:DateTimeType"/>
275
+ # <xsd:element name="STMTTRN" type="ofx:StatementTransaction" minOccurs="0" maxOccurs="unbounded"/>
276
+ # </xsd:sequence>
277
+ # </xsd:complexType>
278
+ #
279
+ # And this is the definition of the type BankAccount.
280
+ #
281
+ # <xsd:complexType name="BankAccount">
282
+ # <xsd:annotation>
283
+ # <xsd:documentation>
284
+ # The OFX elements BANKACCTFROM and BANKACCTTO are of type "BankAccount"
285
+ # </xsd:documentation>
286
+ # </xsd:annotation>
287
+ # <xsd:complexContent>
288
+ # <xsd:extension base="ofx:AbstractAccount">
289
+ # <xsd:sequence>
290
+ # <xsd:element name="BANKID" type="ofx:BankIdType"/>
291
+ # <xsd:element name="BRANCHID" type="ofx:AccountIdType" minOccurs="0"/>
292
+ # <xsd:element name="ACCTID" type="ofx:AccountIdType"/>
293
+ # <xsd:element name="ACCTTYPE" type="ofx:AccountEnum"/>
294
+ # <xsd:element name="ACCTKEY" type="ofx:AccountIdType" minOccurs="0"/>
295
+ # </xsd:sequence>
296
+ # </xsd:extension>
297
+ # </xsd:complexContent>
298
+ # </xsd:complexType>
299
+ #
300
+ # The to_ofx method will only generate the essential elements which are
301
+ # * BANKID - the bank identifier (a 1-9 char string - may be empty)
302
+ # * ACCTID - the account number (a 1-22 char string - may not be empty!)
303
+ # * ACCTTYPE - the type of account - must be one of:
304
+ # "CHECKING", "SAVINGS", "MONEYMRKT", "CREDITLINE"
305
+ #
306
+ # (See Transaction for a definition of STMTTRN)
307
+ #
308
+ def to_ofx
309
+ buf = ""
310
+ # Use Builder to generate XML. Builder works by catching missing_method
311
+ # calls and generating an XML element with the name of the missing method,
312
+ # nesting according to the nesting of the calls and using arguments for content
313
+ x = Builder::XmlMarkup.new(:target => buf, :indent => 2)
314
+ x.OFX {
315
+ x.BANKMSGSRSV1 { #Bank Message Response
316
+ x.STMTTRNRS { #Statement-transaction aggregate response
317
+ x.STMTRS { #Statement response
318
+ x.CURDEF currency #Currency
319
+ x.BANKACCTFROM {
320
+ x.BANKID bank_id # bank identifier
321
+ x.ACCTID account_number
322
+ x.ACCTTYPE account_type # acct type: checking/savings/...
323
+ }
324
+ x.BANKTRANLIST { #Transactions
325
+ x.DTSTART Bankjob.date_time_to_ofx(from_date)
326
+ x.DTEND Bankjob.date_time_to_ofx(to_date)
327
+ transactions.each { |transaction|
328
+ buf << transaction.to_ofx
329
+ }
330
+ }
331
+ x.LEDGERBAL { # the final balance at the end of the statement
332
+ x.BALAMT closing_balance # balance amount
333
+ x.DTASOF Bankjob.date_time_to_ofx(to_date) # balance date
334
+ }
335
+ x.AVAILBAL { # the final Available balance
336
+ x.BALAMT closing_available
337
+ x.DTASOF Bankjob.date_time_to_ofx(to_date)
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ return buf
344
+ end
345
+
346
+ def to_s
347
+ buf = "#{self.class}: close_bal = #{closing_balance}, avail = #{closing_available}, curr = #{currency}, transactions:"
348
+ transactions.each do |tx|
349
+ buf << "\n\t\t#{tx.to_s}"
350
+ end
351
+ buf << "\n---\n"
352
+ return buf
353
+ end
354
+ end # class Statement
355
+ end # module
@@ -0,0 +1,217 @@
1
+
2
+ require 'rubygems'
3
+ require 'bankjob'
4
+
5
+ module Bankjob
6
+
7
+ ##
8
+ # Takes a date-time as a string or as a Time or DateTime object and returns
9
+ # it as either a Time or a DateTime object.
10
+ #
11
+ # This is useful in the setter method of a date attribute allowing the date
12
+ # to be set as any type but stored internally as an object compatible with
13
+ # conversion through +strftime()+
14
+ # (Bankjob::Transaction uses this internally in the setter for +date+ for example
15
+ #
16
+ def self.create_date_time(date_time_raw)
17
+ if (date_time_raw.respond_to?(:rfc822)) then
18
+ # It's already a Time or DateTime
19
+ return date_time_raw
20
+ elsif (date_time_raw.to_s.strip.empty?)
21
+ # Nil or non dates are returned as nil
22
+ return nil
23
+ else
24
+ # Assume it can be converted to a time
25
+ return Time.parse(date_time_raw.to_s)
26
+ end
27
+ end
28
+
29
+ ##
30
+ # Takes a Time or DateTime and formats it in the correct format for OFX date elements.
31
+ #
32
+ # The OFX format is a string of digits in the format "YYMMDDHHMMSS".
33
+ # For example, the 1st of February 2009 at 2:34PM and 56 second becomes "20090201143456"
34
+ #
35
+ # Note must use a Time, or DateTime, not a String, nor a Date.
36
+ #
37
+ def self.date_time_to_ofx(time)
38
+ time.nil? ? "" : "#{time.strftime( '%Y%m%d%H%M%S' )}"
39
+ end
40
+
41
+ ##
42
+ # Takes a Time or DateTime and formats in a suitable format for comma separated values files.
43
+ # The format produced is suitable for loading into an Excel-like spreadsheet program
44
+ # being automatically treated as a date.
45
+ #
46
+ # A string is returned with the format "YY-MM-DD HH:MM:SS".
47
+ # For example, the 1st of February 2009 at 2:34PM and 56 second becomes "2009-02-01 14:34:56"
48
+ #
49
+ # Note must use a Time, or DateTime, not a String, nor a Date.
50
+ #
51
+ def self.date_time_to_csv(time)
52
+ time.nil? ? "" : "#{time.strftime( '%Y-%m-%d %H:%M:%S' )}"
53
+ end
54
+
55
+ ##
56
+ # Takes a string and capitalizes the first letter of every word
57
+ # and forces the rest of the word to be lowercase.
58
+ #
59
+ # This is a utility method for use in scrapers to make descriptions
60
+ # more readable.
61
+ #
62
+ def self.capitalize_words(message)
63
+ message.downcase.gsub(/\b\w/){$&.upcase}
64
+ end
65
+
66
+ ##
67
+ # converts a numeric +string+ to a float given the specified +decimal+
68
+ # separator.
69
+ #
70
+ def self.string_to_float(string, decimal)
71
+ return nil if string.nil?
72
+ amt = string.gsub(/\s/, '')
73
+ if (decimal == ',') # E.g. "1.000.030,99"
74
+ amt.gsub!(/\./, '') # strip out . 1000s separator
75
+ amt.gsub!(/,/, '.') # replace decimal , with .
76
+ elsif (decimal == '.')
77
+ amt.gsub!(/,/, '') # strip out comma 1000s separator
78
+ end
79
+ return amt.to_f
80
+ end
81
+
82
+
83
+ def self.wesabe_upload(wesabe_args, ofx_doc, logger)
84
+ if (wesabe_args.nil? or (wesabe_args.length < 2 and wesabe_args.length > 3))
85
+ raise "Incorrect number of args for Wesabe (#{wesabe_args}), should be 2 or 3."
86
+ else
87
+ load_wesabe
88
+ wuser, wpass, windex = *wesabe_args
89
+ wesabe = Wesabe.new(wuser, wpass)
90
+ num_accounts = wesabe.accounts.length
91
+ if num_accounts == 0
92
+ raise "The user \"#{wuser}\" has no Wesabe accounts. Create one at www.wesabe.com before attempting to upload a statement."
93
+ elsif (not windex.nil? and (num_accounts < windex.to_i))
94
+ raise "The user \"#{wuser}\" has only #{num_accounts} Wesabe accounts, but the account index #{windex} was specified."
95
+ elsif windex.nil?
96
+ if num_accounts > 1
97
+ raise "The user \"#{wuser}\" has #{num_accounts} Wesabe accounts, so the account index must be specified in the WESABE_ARGS."
98
+ else
99
+ # we have only one account, no need to specify the index
100
+ windex = 1
101
+ end
102
+ elsif windex.to_i == 0
103
+ raise "The Wesabe account index must be between 1 and #{num_accounts}. #{windex} is not acceptable"
104
+ end
105
+ logger.debug("Attempting to upload statement to the ##{windex} Wesabe account for user #{wuser}...")
106
+ # Get the account at the index (which is not necessarily the index in the array
107
+ # so we use the account(index) method to get it
108
+ account = wesabe.account(windex.to_i)
109
+ uploader = account.new_upload
110
+ uploader.statement = ofx_doc
111
+ uploader.upload!
112
+ logger.info("Uploaded statement to Wesabe account #{account.name}, the ##{windex} account for user #{wuser}, with the result: #{uploader.status}")
113
+ end
114
+ end
115
+
116
+ def self.wesabe_help(wesabe_args)
117
+ if (wesabe_args.nil? or wesabe_args.length != 2)
118
+ puts <<-EOF
119
+ Wesabe (http://www.wesabe.com) is an online bank account management tool (like Mint)
120
+ that allows you to upload (in some cases automatically) your bank statements and
121
+ automatically convert them into a more readable format to allow you to track
122
+ your spending and much more. Wesabe comes with its own community attached.
123
+
124
+ Bankjob has no affiliation with Wesabe, but allows you to upload the statements it
125
+ generates to your Wesabe account automatically.
126
+
127
+ To use Wesabe you need the Wesabe Ruby gem installed:
128
+ See the gem at http://github.com/wesabe/wesabe-rubygem
129
+ Install the gem with:
130
+ $ sudo gem install -r --source http://gems.github.com/ wesabe-wesabe
131
+ (on Windows, omit the "sudo")
132
+
133
+ You also need your Wesabe login name and password, and, if you have
134
+ more than one account on Wesabe, the id number of the account.
135
+ This is not a real account number - it's simply a counter that Wesabe uses.
136
+ If you have a single account it will be '1', if you have two accounts the
137
+ second account will be '2', etc.
138
+
139
+ Bankjob will help you find this number by listing your Wesabe accounts for you.
140
+ Simply use:
141
+ bankjob -wesabe_help "username password"
142
+ (The quotes are important - this is a single argument to Bankjob with two words)
143
+
144
+ If you already know the number of the account and you want to start uploading use:
145
+
146
+ bankjob [other bankjob args] --wesabe "username password id"
147
+
148
+ E.g.
149
+ bankjob --scraper bpi_scraper.rb --wesabe "bloggsy pw123 2"
150
+
151
+ If you only have a single account, you don't need to specify the id number
152
+ (but Bankjob will check and will fail with an error if you have more than one account)
153
+
154
+ bankjob [other bankjob args] --wesabe "username password"
155
+
156
+ If in any doubt --wesabe-help "username password" will set you straight.
157
+
158
+ Troubleshooting:
159
+ - If you see an error like Wesabe::Request::Unauthorized, then chances
160
+ are your username or password for Wesabe is incorrect.
161
+
162
+ - If you see an error "end of file reached" then it may be that you are logged
163
+ into the Wesabe account to which you are trying to upload - perhaps in a browser.
164
+ In this case, log out from Wesabe in the browser, _wait a minute_, then try again.
165
+ EOF
166
+ else
167
+ load_wesabe
168
+ begin
169
+ puts "Connecting to Wesabe...\n"
170
+ wuser, wpass = *wesabe_args
171
+ wesabe = Wesabe.new(wuser, wpass)
172
+ puts "You have #{wesabe.accounts.length} Wesabe accounts:"
173
+ wesabe.accounts.each do |account|
174
+ puts " Account Name: #{account.name}"
175
+ puts " wesabe id: #{account.id}"
176
+ puts " account no: #{account.number}"
177
+ puts " type: #{account.type}"
178
+ puts " balance: #{account.balance}"
179
+ puts " bank: #{account.financial_institution.name}"
180
+ puts "To upload to this account use:"
181
+ puts " bankjob [other bankjob args] --wesabe \"#{wuser} password #{account.id}\""
182
+ puts ""
183
+ if wesabe.accounts.length == 1
184
+ puts "Since you have one account you do not need to specify the id number, use:"
185
+ puts " bankjob [other bankjob args] --wesabe \"#{wuser} password\""
186
+ end
187
+ end
188
+ rescue Exception => e
189
+ raise <<-EOF
190
+ Failed to get Wesabe account information due to: #{e.message}.
191
+ Check your username and password or use:
192
+ bankjob --wesabe-help
193
+ with no arguments for more details.
194
+ EOF
195
+ end
196
+ end
197
+ end # wesabe_help
198
+
199
+ private
200
+
201
+ def self.load_wesabe(logger = nil)
202
+ begin
203
+ require 'wesabe'
204
+ rescue LoadError => error
205
+ msg = <<-EOF
206
+ Failed to load the Wesabe gem due to #{error.module}
207
+ See the gem at http://github.com/wesabe/wesabe-rubygem
208
+ Install the gem with:
209
+ $ sudo gem install -r --source http://gems.github.com/ wesabe-wesabe
210
+ EOF
211
+ logger.fatal(msg) unless logger.nil?
212
+ raise msg
213
+ end
214
+ end
215
+ end # module Bankjob
216
+
217
+