Class: Bankjob::Statement

Inherits:
Object
  • Object
show all
Defined in:
lib/bankjob/statement.rb

Overview

A Statement object represents a bank statement and is generally the result of running a Bankjob scraper. The Statement holds an array of Transaction objects and specifies the closing balance and the currency in use.

A Scraper will create a Statement by scraping web pages in an online banking site. The Statement can then be stored as a file in CSV (Comma Separated Values) format using to_csv or in OFX (Open Financial eXchange www.ofx.net) format using to_ofx.

One special ability of Statement is the ability to merge with an existing statement, automatically eliminating overlapping transactions. This means that when writing subsequent statements to the same CSV file (note well: CSV only) a continous transaction record can be built up over a long period.

Constant Summary collapse

CHECKING =

OFX value for the ACCTTYPE of a checking account

"CHECKING"
SAVINGS =

OFX value for the ACCTTYPE of a savings account

"SAVINGS"
MONEYMRKT =

OFX value for the ACCTTYPE of a money market account

"MONEYMRKT"
CREDITLINE =

OFX value for the ACCTTYPE of a loan account

"CREDITLINE"
ONE_MINUTE =
60
ELEVEN_59_PM =

seconds at 23:59

23 * 60 * 60 + 59 * 60
MIDDAY =
12 * 60 * 60

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(account_number, currency = "EUR") ⇒ Statement

Creates a new empty Statement with no transactions. The account_number must be specified as a 1-22 character string. The specified currency defaults to EUR if nothing is passed in.



83
84
85
86
87
88
89
90
# File 'lib/bankjob/statement.rb', line 83

def initialize(, currency = "EUR")
  @account_number = 
  @currency = currency
  @transactions = []
  @account_type = CHECKING
  @closing_balance = nil
  @closing_available = nil
end

Instance Attribute Details

#account_numberObject

the account number of the statement - a 1-22 char string that must be passed into the initalizer of the Statement Translates to the OFX element ACCTID



59
60
61
# File 'lib/bankjob/statement.rb', line 59

def 
  @account_number
end

#account_typeObject

the type of bank account the statement is for Tranlsates to the OFX type ACCTTYPE and must be one of

  • CHECKING

  • SAVINGS

  • MONEYMRKT

  • CREDITLINE

Use a constant to set this - defaults to CHECKING



68
69
70
# File 'lib/bankjob/statement.rb', line 68

def 
  @account_type
end

#bank_idObject

the identifier of the bank - a 1-9 char string (may be empty) Translates to the OFX element BANKID



54
55
56
# File 'lib/bankjob/statement.rb', line 54

def bank_id
  @bank_id
end

#closing_availableObject

the avaliable funds in the account after the last transaction in the statement (generally the same as closing_balance) Translates to the OFX element BALAMT in AVAILBAL



43
44
45
# File 'lib/bankjob/statement.rb', line 43

def closing_available
  @closing_available
end

#closing_balanceObject

the account balance after the last transaction in the statement Translates to the OFX element BALAMT in LEDGERBAL



39
40
41
# File 'lib/bankjob/statement.rb', line 39

def closing_balance
  @closing_balance
end

#currencyObject (readonly)

the three-letter currency symbol generated into the OFX output (defaults to EUR) This is passed into the initializer (usually by the Scraper - see Scraper#currency)



50
51
52
# File 'lib/bankjob/statement.rb', line 50

def currency
  @currency
end

#from_dateObject

the first date of the period the statement covers Translates to the OFX element DTSTART



76
77
78
# File 'lib/bankjob/statement.rb', line 76

def from_date
  @from_date
end

#to_dateObject

the last date of the period the statement covers Translates to the OFX element DTEND



72
73
74
# File 'lib/bankjob/statement.rb', line 72

def to_date
  @to_date
end

#transactionsObject

the array of Transaction objects that comprise the statement



46
47
48
# File 'lib/bankjob/statement.rb', line 46

def transactions
  @transactions
end

Class Method Details

.csv_headerObject

Generates a string for use as a header in a CSV file for a statement.

Delegates to Transaction#csv_header



181
182
183
# File 'lib/bankjob/statement.rb', line 181

def self.csv_header
  return Transaction.csv_header
end

Instance Method Details

#==(other) ⇒ Object

Overrides == to allow comparison of Statement objects. Two Statements are considered equal (that is, ==) if and only iff they have the same values for:

  • to_date

  • from_date

  • closing_balance

  • closing_available

  • each and every transaction.

Note that the transactions are compared with Transaction.==



110
111
112
113
114
115
116
117
118
119
# File 'lib/bankjob/statement.rb', line 110

def ==(other) # :nodoc:
  if other.kind_of?(Statement) 
    return (from_date == other.from_date and
        to_date == other.to_date and
        closing_balance == other.closing_balance and
        closing_available == other.closing_available and
        transactions == other.transactions)
  end
  return false
end

#add_transaction(transaction) ⇒ Object

Appends a new Transaction to the end of this Statement



95
96
97
# File 'lib/bankjob/statement.rb', line 95

def add_transaction(transaction)
  @transactions << transaction
end

#finish(most_recent_first, fake_times = false) ⇒ Object

Finishes the statement after scraping in two ways depending on the information that the scraper was able to obtain. Optionally have your scraper class call this after scraping is finished.

This method:

  1. Sets the closing balance and available_balance and the to_ and from_dates by using the first and last transactions in the list. Which transaction is used depends on whether most_recent_first is true or false. The scraper may just set these directly in which case this may not be necessary.

  2. If fake_times is true time-stamps are invented and added to the transaction date attributes. This is useful if the website beings scraped shows dates, but not times, but has transactions listed in chronoligical arder. Without this process, the ofx generated has no proper no indication of the order of transactions that occurred in the same day other than the order in the statement and this may be ignored by the client. (Specifically, Wesabe will reorder transactions in the same day if they all appear to occur at the same time).

    Note that the algorithm to set the fake times is a little tricky. Assuming the transactionsa are most-recent-first, the first last transaction on each day is set at 11:59pm each transaction prior to that is one minute earlier.

    But for the first transactions in the statement, the first is set at a few minutes after midnight, then we count backward. (The actual number of minutes is based on the number of transactions + 1 to be sure it doesnt pass midnight)

    This is crucial because transactions for a given day will often span 2 or more statement. By starting just after midnight and going back to just before midnight we reduce the chance of overlap.

    If the to-date is the same as the from-date for a transaction, then we start at midday, so that prior and subsequent statements don’t overlap.

This simple algorithm basically guarantees no overlaps so long as:
i.  The number of transactions is small compared to the number of minutes in a day
ii. A single day will not span more than 3 statements

If the statement is most-recent-last (+most_recent_first = false+) the same
algorithm is applied, only in reverse


363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'lib/bankjob/statement.rb', line 363

def finish(most_recent_first, fake_times=false)
  if !@transactions.empty? then
    # if the user hasn't set the balances, set them to the first or last
    # transaction balance depending on the order
    if most_recent_first then
      @closing_balance ||= transactions.first.new_balance
      @closing_available ||= transactions.first.new_balance
      @to_date ||= transactions.first.date
      @from_date ||= transactions.last.date
    else
      @closing_balance ||= transactions.last.new_balance
      @closing_available ||= transactions.last.new_balance
      @to_date ||= transactions.last.date
      @from_date ||= transactions.first.date
    end

    if fake_times and to_date.hour == 0 then
      # the statement was unable to scrape times to go with the dates, but the
      # client (say wesabe) will get the transaction order wrong if there are no
      # times, so here we add times that order the transactions according to the
      # order of the array of transactions

      # the delta is 1 minute forward or backward fr
      if to_date == from_date then
        # all of the statement's transactions occur in the same day - to try to
        # avoid overlap with subsequent or previous transacitons we group order them
        # from 11am onward
        seconds = MIDDAY
      else
        seconds = (transactions.length + 1) * 60
      end

      if most_recent_first then
        yday = transactions.first.date.yday
        start = 0
        delta = 1
        finish = transactions.length
      else
        yday = transactions.last.date.yday
        start = transactions.length - 1
        finish = -1
        delta = -1
      end

      i = start
      until i == finish
        tx = transactions[i]
        if tx.date.yday != yday
          # starting a new day, begin the countdown from 23:59 again
          yday = tx.date.yday
          seconds = ELEVEN_59_PM
        end
        tx.date += seconds unless tx.date.hour > 0
        seconds -= ONE_MINUTE
        i += delta
      end
    end
  end
end

#from_csv(source, decimal = ".") ⇒ Object

Reads in transactions from a CSV file or string specified by source and adds them to this statement.

Uses a simple (dumb) heuristic to determine if the source is a file or a string: if it contains a comma (,) then it is a string otherwise it is treated as a file path.



193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/bankjob/statement.rb', line 193

def from_csv(source, decimal = ".")
  if (source =~ /,/)
    # assume source is a string
    FasterCSV.parse(source) do |row|
      add_transaction(Transaction.from_csv(row, decimal))
    end
  else
    # assume source is a filepath
    FasterCSV.foreach(source) do |row|
      add_transaction(Transaction.from_csv(row, decimal))
    end
  end
end

#merge(other) ⇒ Object

Merges the transactions of other into the transactions of this statement and returns the result. Neither statement is changed. See #merge! if you want to modify the statement. Raises an exception if the two statements overlap in a discontiguous fashion.



142
143
144
145
146
147
148
149
# File 'lib/bankjob/statement.rb', line 142

def merge(other)
  union = merge_transactions(other)
  merged = self.dup
  merged.closing_balance = nil
  merged.closing_available = nil
  merged.transactions = union
  return merged
end

#merge!(other) ⇒ Object

Merges the transactions of other into the transactions of this statement. Causes this statement to be changed. See #merge for details.



155
156
157
158
159
# File 'lib/bankjob/statement.rb', line 155

def merge!(other)
  @closing_balance = nil
  @closing_available = nil
  @transactions = merge_transactions(other)
end

#merge_transactions(other) ⇒ Object

Merges the transactions of other into the transactions of this statement and returns the resulting array of transactions Raises an exception if the two statements overlap in a discontiguous fashion.



126
127
128
129
130
131
132
133
134
# File 'lib/bankjob/statement.rb', line 126

def merge_transactions(other)
  if (other.kind_of?(Statement))
    union = transactions | other.transactions # the set union of both
    # now check that the union contains all of the originals, otherwise
    # we have merged some sort of non-contiguous range
    raise "Failed to merge transactions properly." unless union.first(@transactions.length) == @transactions
    return union
  end
end

#to_csvObject

Generates a CSV (comma separated values) string with a single row for each transaction. Note that no header row is generated as it would make it difficult to concatenate and merge subsequent CSV strings (but we should consider it as a user option in the future)



168
169
170
171
172
173
174
# File 'lib/bankjob/statement.rb', line 168

def to_csv
  buf = ""
  transactions.each do |transaction|
    buf << transaction.to_csv
  end
  return buf
end

#to_ofxObject

<xsd:sequence>

        <xsd:element name="BANKID" type="ofx:BankIdType"/>
        <xsd:element name="BRANCHID" type="ofx:AccountIdType" minOccurs="0"/>
        <xsd:element name="ACCTID" type="ofx:AccountIdType"/>
        <xsd:element name="ACCTTYPE" type="ofx:AccountEnum"/>
        <xsd:element name="ACCTKEY" type="ofx:AccountIdType" minOccurs="0"/>
      </xsd:sequence>
    </xsd:extension>
  </xsd:complexContent>
</xsd:complexType>

The to_ofx method will only generate the essential elements which are

  • BANKID - the bank identifier (a 1-9 char string - may be empty)

  • ACCTID - the account number (a 1-22 char string - may not be empty!)

  • ACCTTYPE - the type of account - must be one of:

    "CHECKING", "SAVINGS", "MONEYMRKT", "CREDITLINE"
    

(See Transaction for a definition of STMTTRN)



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/bankjob/statement.rb', line 279

def to_ofx
  buf = ""
  # Use Builder to generate XML. Builder works by catching missing_method
  # calls and generating an XML element with the name of the missing method,
  # nesting according to the nesting of the calls and using arguments for content
  x = Builder::XmlMarkup.new(:target => buf, :indent => 2)
  x.OFX {
    x.BANKMSGSRSV1 { #Bank Message Response
      x.STMTTRNRS {		#Statement-transaction aggregate response
        x.STMTRS {		#Statement response
          x.CURDEF currency	#Currency
          x.BANKACCTFROM {
            x.BANKID bank_id # bank identifier
            x.ACCTID 
            x.ACCTTYPE  # acct type: checking/savings/...
          }
          x.BANKTRANLIST {	#Transactions
            x.DTSTART Bankjob.date_time_to_ofx(from_date)
            x.DTEND Bankjob.date_time_to_ofx(to_date)
            transactions.each { |transaction|
              buf << transaction.to_ofx
            }
          }
          x.LEDGERBAL {	# the final balance at the end of the statement
            x.BALAMT closing_balance # balance amount
            x.DTASOF Bankjob.date_time_to_ofx(to_date)		# balance date
          }
          x.AVAILBAL {	# the final Available balance
            x.BALAMT closing_available
            x.DTASOF Bankjob.date_time_to_ofx(to_date)
          }
        }
      }
    }
  }
  return buf
end

#to_sObject



423
424
425
426
427
428
429
430
# File 'lib/bankjob/statement.rb', line 423

def to_s
  buf = "#{self.class}: close_bal = #{closing_balance}, avail = #{closing_available}, curr = #{currency}, transactions:"
  transactions.each do |tx|
    buf << "\n\t\t#{tx.to_s}"
  end
  buf << "\n---\n"
  return buf
end