coop_to_ofx 1.0.1
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/README.rdoc +38 -0
- data/Rakefile +141 -0
- data/bin/coop_to_ofx +60 -0
- data/lib/coop_scraper/base.rb +8 -0
- data/lib/coop_scraper/credit_card.rb +94 -0
- data/lib/coop_scraper/current_account.rb +92 -0
- data/lib/coop_scraper/version.rb +12 -0
- data/lib/coop_scraper.rb +2 -0
- data/lib/ofx/statement/base.rb +53 -0
- data/lib/ofx/statement/credit_card.rb +15 -0
- data/lib/ofx/statement/current_account.rb +14 -0
- data/lib/ofx/statement/output/base.rb +131 -0
- data/lib/ofx/statement/output/builder.rb +76 -0
- data/lib/ofx/statement/output/credit_card.rb +31 -0
- data/lib/ofx/statement/output/current_account.rb +29 -0
- data/lib/ofx/statement/transaction.rb +52 -0
- data/lib/ofx/statement.rb +3 -0
- data/lib/ofx.rb +1 -0
- data/spec/coop_scraper/base_spec.rb +15 -0
- data/spec/coop_scraper/credit_card_spec.rb +229 -0
- data/spec/coop_scraper/current_account_spec.rb +154 -0
- data/spec/fixtures/credit_card/cc_statement_fixture.html +927 -0
- data/spec/fixtures/credit_card/foreign_transaction_fixture.html +447 -0
- data/spec/fixtures/credit_card/interest_transaction_fixture.html +438 -0
- data/spec/fixtures/credit_card/maybe.txt +43 -0
- data/spec/fixtures/credit_card/merchandise_interest_fixture.html +0 -0
- data/spec/fixtures/credit_card/normal_transaction_fixture.html +439 -0
- data/spec/fixtures/credit_card/overlimit_charge_fixture.html +446 -0
- data/spec/fixtures/credit_card/payment_in_transaction_fixture.html +439 -0
- data/spec/fixtures/credit_card/simple_cc_statement.ofx +43 -0
- data/spec/fixtures/credit_card/statement_with_interest_line_fixture.html +452 -0
- data/spec/fixtures/current_account/cash_point_transaction_fixture.html +372 -0
- data/spec/fixtures/current_account/current_account_fixture.html +420 -0
- data/spec/fixtures/current_account/current_account_fixture.ofx +83 -0
- data/spec/fixtures/current_account/debit_interest_transaction_fixture.html +372 -0
- data/spec/fixtures/current_account/no_transactions_fixture.html +364 -0
- data/spec/fixtures/current_account/normal_transaction_fixture.html +372 -0
- data/spec/fixtures/current_account/payment_in_transaction_fixture.html +372 -0
- data/spec/fixtures/current_account/service_charge_transaction_fixture.html +372 -0
- data/spec/fixtures/current_account/transfer_transaction_fixture.html +372 -0
- data/spec/ofx/statement/base_spec.rb +116 -0
- data/spec/ofx/statement/credit_card_spec.rb +20 -0
- data/spec/ofx/statement/current_account_spec.rb +20 -0
- data/spec/ofx/statement/output/base_spec.rb +249 -0
- data/spec/ofx/statement/output/builder_spec.rb +38 -0
- data/spec/ofx/statement/output/credit_card_spec.rb +84 -0
- data/spec/ofx/statement/output/current_account_spec.rb +81 -0
- data/spec/ofx/statement/transaction_spec.rb +76 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +36 -0
- metadata +172 -0
data/README.rdoc
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
Convert statement HTML from the Co-operative bank's online banking system to OFX for import into financial apps.
|
2
|
+
|
3
|
+
= Usage
|
4
|
+
|
5
|
+
For a Current Account:
|
6
|
+
|
7
|
+
1. Save the HTML source of the statement page.
|
8
|
+
|
9
|
+
coop_to_ofx --current /path/to/statement.html
|
10
|
+
|
11
|
+
Will produce /path/to/statement.ofx
|
12
|
+
|
13
|
+
For a Credit Card:
|
14
|
+
|
15
|
+
1. Save the HTML source of the statement page
|
16
|
+
|
17
|
+
coop_to_ofx /path/to/statement.html
|
18
|
+
|
19
|
+
Or
|
20
|
+
|
21
|
+
coop_to_ofx --credit /path/to/statement.html
|
22
|
+
|
23
|
+
Will produce /path/to/statement.ofx
|
24
|
+
|
25
|
+
|
26
|
+
To produce OFX 1 SGML (rather than OFX 2 XML):
|
27
|
+
|
28
|
+
coop_to_ofx --ofx1 /path/to/statement.html
|
29
|
+
coop_to_ofx --ofx1 --current /path/to/statement.html
|
30
|
+
|
31
|
+
To show all the options:
|
32
|
+
|
33
|
+
coop_to_ofx --help
|
34
|
+
|
35
|
+
|
36
|
+
== To do
|
37
|
+
|
38
|
+
XML / SGML validation of output against the specs
|
data/Rakefile
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/rdoctask'
|
3
|
+
gem 'rspec'
|
4
|
+
require 'spec/rake/spectask'
|
5
|
+
require 'lib/coop_scraper/version.rb'
|
6
|
+
|
7
|
+
desc 'Generate documentation for Co-op-to-OFX.'
|
8
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
9
|
+
rdoc.rdoc_dir = 'rdoc'
|
10
|
+
rdoc.title = 'Co-op to OFX'
|
11
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
12
|
+
rdoc.rdoc_files.include('README.rdoc')
|
13
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
14
|
+
end
|
15
|
+
|
16
|
+
task :default => :spec
|
17
|
+
|
18
|
+
desc "Run all specs in spec directory (excluding plugin specs)"
|
19
|
+
Spec::Rake::SpecTask.new(:spec) do |t|
|
20
|
+
t.spec_opts = ['--options', "\"#{Rake.original_dir}/spec/spec.opts\""]
|
21
|
+
t.spec_files = FileList['spec/**/*_spec.rb']
|
22
|
+
end
|
23
|
+
|
24
|
+
namespace :spec do
|
25
|
+
desc "Run all specs in spec directory with RCov (excluding plugin specs)"
|
26
|
+
Spec::Rake::SpecTask.new(:rcov) do |t|
|
27
|
+
t.spec_opts = ['--options', "\"#{Rake.original_dir}/spec/spec.opts\""]
|
28
|
+
t.spec_files = FileList['spec/**/*_spec.rb']
|
29
|
+
t.rcov = true
|
30
|
+
t.rcov_opts = lambda do
|
31
|
+
IO.readlines("#{Rake.original_dir}/spec/rcov.opts").map {|l| l.chomp.split " "}.flatten
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "Print Specdoc for all specs (excluding plugin specs)"
|
36
|
+
Spec::Rake::SpecTask.new(:doc) do |t|
|
37
|
+
t.spec_opts = ["--format", "specdoc", "--dry-run"]
|
38
|
+
t.spec_files = FileList['spec/**/*_spec.rb']
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
require "rubygems"
|
43
|
+
require "rake/gempackagetask"
|
44
|
+
|
45
|
+
# This builds the actual gem. For details of what all these options
|
46
|
+
# mean, and other ones you can add, check the documentation here:
|
47
|
+
#
|
48
|
+
# http://rubygems.org/read/chapter/20
|
49
|
+
#
|
50
|
+
spec = Gem::Specification.new do |s|
|
51
|
+
|
52
|
+
# Change these as appropriate
|
53
|
+
s.name = "coop_to_ofx"
|
54
|
+
s.version = CoopScraper::Version()
|
55
|
+
s.summary = "Convert Co-operative bank HTML statements into OFX"
|
56
|
+
s.description = File.read('README.rdoc')
|
57
|
+
s.author = "Matt Patterson"
|
58
|
+
s.email = "matt@reprocessed.org"
|
59
|
+
s.homepage = "http://reprocessed.org/"
|
60
|
+
|
61
|
+
s.has_rdoc = true
|
62
|
+
s.extra_rdoc_files = %w(README.rdoc)
|
63
|
+
s.rdoc_options = %w(--main README.rdoc)
|
64
|
+
|
65
|
+
# Add any extra files to include in the gem
|
66
|
+
s.files = %w(Rakefile README.rdoc) + Dir.glob("{bin,spec,lib}/**/*")
|
67
|
+
s.executables = FileList["bin/**"].map { |f| File.basename(f) }
|
68
|
+
|
69
|
+
s.require_paths = ["lib"]
|
70
|
+
|
71
|
+
# If you want to depend on other gems, add them here, along with any
|
72
|
+
# relevant versions
|
73
|
+
s.add_dependency("hpricot", "~> 0.6.0")
|
74
|
+
s.add_dependency("builder", "~> 2.1.0")
|
75
|
+
|
76
|
+
s.add_development_dependency("rspec") # add any other gems for testing/development
|
77
|
+
|
78
|
+
# If you want to publish automatically to rubyforge, you'll may need
|
79
|
+
# to tweak this, and the publishing task below too.
|
80
|
+
s.rubyforge_project = "coop_to_ofx"
|
81
|
+
end
|
82
|
+
|
83
|
+
# This task actually builds the gem. We also regenerate a static
|
84
|
+
# .gemspec file, which is useful if something (i.e. GitHub) will
|
85
|
+
# be automatically building a gem for this project. If you're not
|
86
|
+
# using GitHub, edit as appropriate.
|
87
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
88
|
+
pkg.gem_spec = spec
|
89
|
+
|
90
|
+
# Generate the gemspec file for github.
|
91
|
+
file = File.dirname(__FILE__) + "/#{spec.name}.gemspec"
|
92
|
+
File.open(file, "w") {|f| f << spec.to_ruby }
|
93
|
+
end
|
94
|
+
|
95
|
+
desc 'Clear out RDoc and generated packages'
|
96
|
+
task :clean => [:clobber_rdoc, :clobber_package] do
|
97
|
+
rm "#{spec.name}.gemspec"
|
98
|
+
end
|
99
|
+
|
100
|
+
# If you want to publish to RubyForge automatically, here's a simple
|
101
|
+
# task to help do that. If you don't, just get rid of this.
|
102
|
+
# Be sure to set up your Rubyforge account details with the Rubyforge
|
103
|
+
# gem; you'll need to run `rubyforge setup` and `rubyforge config` at
|
104
|
+
# the very least.
|
105
|
+
begin
|
106
|
+
require "rake/contrib/sshpublisher"
|
107
|
+
namespace :rubyforge do
|
108
|
+
|
109
|
+
desc "Release gem and RDoc documentation to RubyForge"
|
110
|
+
task :release => ["rubyforge:release:gem", "rubyforge:release:docs"]
|
111
|
+
|
112
|
+
namespace :release do
|
113
|
+
desc "Release a new version of this gem"
|
114
|
+
task :gem => [:package] do
|
115
|
+
require 'rubyforge'
|
116
|
+
rubyforge = RubyForge.new
|
117
|
+
rubyforge.configure
|
118
|
+
rubyforge.login
|
119
|
+
rubyforge.userconfig['release_notes'] = spec.summary
|
120
|
+
path_to_gem = File.join(File.dirname(__FILE__), "pkg", "#{spec.name}-#{spec.version}.gem")
|
121
|
+
puts "Publishing #{spec.name}-#{spec.version.to_s} to Rubyforge..."
|
122
|
+
rubyforge.add_release(spec.rubyforge_project, spec.name, spec.version.to_s, path_to_gem)
|
123
|
+
end
|
124
|
+
|
125
|
+
desc "Publish RDoc to RubyForge."
|
126
|
+
task :docs => [:rdoc] do
|
127
|
+
config = YAML.load(
|
128
|
+
File.read(File.expand_path('~/.rubyforge/user-config.yml'))
|
129
|
+
)
|
130
|
+
|
131
|
+
host = "#{config['username']}@rubyforge.org"
|
132
|
+
remote_dir = "/var/www/gforge-projects/coop_to_ofx/" # Should be the same as the rubyforge project name
|
133
|
+
local_dir = 'rdoc'
|
134
|
+
|
135
|
+
Rake::SshDirPublisher.new(host, remote_dir, local_dir).upload
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
rescue LoadError
|
140
|
+
puts "Rake SshDirPublisher is unavailable or your rubyforge environment is not configured."
|
141
|
+
end
|
data/bin/coop_to_ofx
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$:.push(File.expand_path(File.dirname(__FILE__) + '/../lib'))
|
4
|
+
require 'optparse'
|
5
|
+
require 'coop_scraper/version'
|
6
|
+
|
7
|
+
options = {:format => :ofx2, :statement_type => :credit}
|
8
|
+
OptionParser.new do |opts|
|
9
|
+
opts.banner = "Usage: coop_to_ofx [[--ofx1 | --ofx2] [--current | --credit]] /path/to/statement.html"
|
10
|
+
|
11
|
+
opts.on("--ofx1", "Generate OFX 1 SGML output") do |format|
|
12
|
+
options[:format] = :ofx1
|
13
|
+
end
|
14
|
+
|
15
|
+
opts.on("--ofx2", "Generate OFX 2 XML output (the default)") do |format|
|
16
|
+
options[:format] = :ofx1
|
17
|
+
end
|
18
|
+
|
19
|
+
opts.on("--current", "Process Current account statements") do |statement_type|
|
20
|
+
options[:statement_type] = :current
|
21
|
+
end
|
22
|
+
|
23
|
+
opts.on("--credit", "Process Credit Card account statements (the default)") do |statement_type|
|
24
|
+
options[:statement_type] = :credit
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.separator ""
|
28
|
+
opts.separator "Common options:"
|
29
|
+
|
30
|
+
# No argument, shows at tail. This will print an options summary.
|
31
|
+
# Try it and see!
|
32
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
33
|
+
puts opts
|
34
|
+
exit
|
35
|
+
end
|
36
|
+
|
37
|
+
# Another typical switch to print the version.
|
38
|
+
opts.on_tail("--version", "Show version") do
|
39
|
+
puts CoopScraper::Version()
|
40
|
+
puts "Copyright (c) 2009, Matt Patterson. Released under the MIT license"
|
41
|
+
exit
|
42
|
+
end
|
43
|
+
end.parse!
|
44
|
+
|
45
|
+
input_path = ARGV[0]
|
46
|
+
raise ArgumentError, "Argument must be a real file!" unless File.file?(ARGV[0])
|
47
|
+
create_time = File.stat(input_path).ctime
|
48
|
+
|
49
|
+
output_file_path = File.dirname(input_path) + "/#{File.basename(input_path, '.html')}.ofx"
|
50
|
+
|
51
|
+
if options[:statement_type] == :credit
|
52
|
+
require 'coop_scraper/credit_card'
|
53
|
+
scraper_class = CoopScraper::CreditCard
|
54
|
+
else
|
55
|
+
require 'coop_scraper/current_account'
|
56
|
+
scraper_class = CoopScraper::CurrentAccount
|
57
|
+
end
|
58
|
+
|
59
|
+
statement = scraper_class.generate_statement(File.open(input_path), create_time)
|
60
|
+
File.open(output_file_path, 'w') { |f| f.write(statement.serialise(options[:format])) }
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'hpricot'
|
3
|
+
|
4
|
+
require 'coop_scraper/base'
|
5
|
+
require 'ofx/statement'
|
6
|
+
|
7
|
+
module CoopScraper
|
8
|
+
class CreditCard
|
9
|
+
class << self
|
10
|
+
include CoopScraper::Base
|
11
|
+
|
12
|
+
def extract_statement_date(doc)
|
13
|
+
coop_date_to_time(doc.at("td[text()='Statement Date'] ~ td").inner_text)
|
14
|
+
end
|
15
|
+
|
16
|
+
def extract_account_number(doc)
|
17
|
+
doc.at("h4[text()*='TRAVEL CARD']").inner_text.match(/([0-9]{16})/)[1]
|
18
|
+
end
|
19
|
+
|
20
|
+
def extract_statement_balance(doc)
|
21
|
+
amount, sign = doc.at("td[text()='Statement Balance'] ~ td").inner_text.match(/([0-9.]+) *(DR)?/).captures
|
22
|
+
amount = "-#{amount}" if sign == "DR"
|
23
|
+
amount
|
24
|
+
end
|
25
|
+
|
26
|
+
def extract_available_credit(doc)
|
27
|
+
doc.at("td[text()='Available Credit'] ~ td").inner_text.match(/[0-9.]+/).to_s
|
28
|
+
end
|
29
|
+
|
30
|
+
def determine_trntype(details)
|
31
|
+
case details
|
32
|
+
when /^OVERLIMIT CHARGE$/
|
33
|
+
:service_charge
|
34
|
+
when /^MERCHANDISE INTEREST AT [0-9]+\.[0-9]+% PER MTH$/
|
35
|
+
:interest
|
36
|
+
else
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def extract_transactions(doc, statement)
|
42
|
+
transactions = []
|
43
|
+
current_transaction = {}
|
44
|
+
doc.search('tbody.contents tr').each do |statement_row|
|
45
|
+
date = statement_row.at('td.dataRowL').inner_text
|
46
|
+
unless date == "?" || date[0] == 194
|
47
|
+
current_transaction = extract_transaction(statement_row, coop_date_to_time(date))
|
48
|
+
transactions << current_transaction
|
49
|
+
else
|
50
|
+
transaction = extract_transaction(statement_row, statement.date)
|
51
|
+
if transaction[:details].match(/OVERLIMIT CHARGE/)
|
52
|
+
transactions << transaction
|
53
|
+
else
|
54
|
+
conversion_details = transaction[:details]
|
55
|
+
current_transaction[:conversion] = conversion_details unless conversion_details.match(/ESTIMATED INTEREST/)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
transactions.collect do |t|
|
60
|
+
options = {}
|
61
|
+
options[:memo] = t[:conversion] if t[:conversion]
|
62
|
+
trntype = determine_trntype(t[:details])
|
63
|
+
options[:trntype] = trntype unless trntype.nil?
|
64
|
+
OFX::Statement::Transaction.new(t[:amount], t[:date], t[:details], options)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def extract_transaction(statement_row, date)
|
69
|
+
details = statement_row.at('td.transData').inner_text.strip
|
70
|
+
credit = statement_row.at('td.moneyData:first').inner_text.match(/[0-9.]+/)
|
71
|
+
debit = statement_row.at('td.moneyData:last').inner_text.match(/[0-9.]+/)
|
72
|
+
amount = credit.nil? ? "-#{debit}" : credit.to_s
|
73
|
+
{:date => date, :amount => amount, :details => details}
|
74
|
+
end
|
75
|
+
|
76
|
+
def generate_statement(html_statement_io, server_response_time)
|
77
|
+
doc = Hpricot(html_statement_io)
|
78
|
+
statement = OFX::Statement::CreditCard.new
|
79
|
+
|
80
|
+
statement.server_response_time = server_response_time
|
81
|
+
statement.account_number = extract_account_number(doc)
|
82
|
+
statement.date = extract_statement_date(doc)
|
83
|
+
statement.ledger_balance = extract_statement_balance(doc)
|
84
|
+
statement.available_credit = extract_available_credit(doc)
|
85
|
+
|
86
|
+
extract_transactions(doc, statement).each { |transaction| statement << transaction }
|
87
|
+
|
88
|
+
statement.start_date = statement.transactions.first.date
|
89
|
+
statement.end_date = statement.date
|
90
|
+
statement
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'hpricot'
|
3
|
+
|
4
|
+
require 'coop_scraper/base'
|
5
|
+
require 'ofx/statement'
|
6
|
+
|
7
|
+
module CoopScraper
|
8
|
+
module CurrentAccount
|
9
|
+
class << self
|
10
|
+
include CoopScraper::Base
|
11
|
+
|
12
|
+
def extract_account_number(doc)
|
13
|
+
doc.at("h4[text()*='CURRENT ACCOUNT']").inner_text.match(/([0-9]{8})/)[1]
|
14
|
+
end
|
15
|
+
|
16
|
+
def extract_sort_code(doc)
|
17
|
+
doc.at("h4[text()*='CURRENT ACCOUNT']").inner_text.match(/([0-9]{2}-[0-9]{2}-[0-9]{2})/)[1].tr('-', '')
|
18
|
+
end
|
19
|
+
|
20
|
+
def extract_statement_date(doc)
|
21
|
+
coop_date_to_time(doc.at("td[text()^='Date']").inner_text)
|
22
|
+
end
|
23
|
+
|
24
|
+
def extract_transaction_rows(doc)
|
25
|
+
a_td = doc.at('td.transData')
|
26
|
+
a_td.parent.parent.search('tr')
|
27
|
+
end
|
28
|
+
|
29
|
+
def determine_trntype(details)
|
30
|
+
case details
|
31
|
+
when /^DEBIT INTEREST$/
|
32
|
+
:interest
|
33
|
+
when /^LINK +[0-9]{2}:[0-9]{2}[A-Z]{3}[0-9]{2}$/
|
34
|
+
:atm
|
35
|
+
when /^SERVICE CHARGE$/
|
36
|
+
:service_charge
|
37
|
+
when /^TFR [0-9]{14}$/
|
38
|
+
:transfer
|
39
|
+
else
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def extract_transactions(doc)
|
45
|
+
transactions = []
|
46
|
+
a_td = doc.at('td.transData')
|
47
|
+
transaction_rows = extract_transaction_rows(doc)
|
48
|
+
first_row = transaction_rows.shift
|
49
|
+
transaction_rows.each do |statement_row|
|
50
|
+
date = statement_row.at('td.dataRowL').inner_text
|
51
|
+
details = statement_row.at('td.transData').inner_text.strip
|
52
|
+
credit = statement_row.at('td.moneyData:first').inner_text.match(/[0-9.]+/)
|
53
|
+
debit = statement_row.search('td.moneyData')[1].inner_text.match(/[0-9.]+/)
|
54
|
+
amount = credit.nil? ? "-#{debit}" : credit.to_s
|
55
|
+
options = {}
|
56
|
+
trntype = determine_trntype(details)
|
57
|
+
options[:trntype] = trntype unless trntype.nil?
|
58
|
+
transactions << OFX::Statement::Transaction.new(amount, coop_date_to_time(date), details, options)
|
59
|
+
end
|
60
|
+
transactions
|
61
|
+
end
|
62
|
+
|
63
|
+
def extract_closing_balance(doc)
|
64
|
+
final_transaction = extract_transaction_rows(doc).last.at('td.moneyData:last').inner_text
|
65
|
+
amount = final_transaction.match(/[0-9.]+/).to_s
|
66
|
+
sign = final_transaction.match(/[CD]R/).to_s
|
67
|
+
sign == "CR" ? amount : "-#{amount}"
|
68
|
+
end
|
69
|
+
|
70
|
+
def extract_statement_start_date(doc)
|
71
|
+
coop_date_to_time(extract_transaction_rows(doc).first.at('td.dataRowL').inner_text)
|
72
|
+
end
|
73
|
+
|
74
|
+
def generate_statement(html_statement_io, server_response_time)
|
75
|
+
doc = Hpricot(html_statement_io)
|
76
|
+
statement = OFX::Statement::CurrentAccount.new
|
77
|
+
|
78
|
+
statement.server_response_time = server_response_time
|
79
|
+
statement.account_number = extract_account_number(doc)
|
80
|
+
statement.sort_code = extract_sort_code(doc)
|
81
|
+
statement.date = extract_statement_date(doc)
|
82
|
+
statement.ledger_balance = extract_closing_balance(doc)
|
83
|
+
|
84
|
+
extract_transactions(doc).each { |transaction| statement << transaction }
|
85
|
+
|
86
|
+
statement.start_date = extract_statement_start_date(doc)
|
87
|
+
statement.end_date = extract_statement_date(doc)
|
88
|
+
statement
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/coop_scraper.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'builder'
|
3
|
+
require 'digest/sha1'
|
4
|
+
|
5
|
+
module OFX
|
6
|
+
module Statement
|
7
|
+
class Base
|
8
|
+
attr_reader :builder_class
|
9
|
+
attr_writer :currency, :server_response_time, :language
|
10
|
+
attr_accessor :account_number, :start_date, :end_date,
|
11
|
+
:date, :ledger_balance
|
12
|
+
|
13
|
+
def initialize(format = :ofx2)
|
14
|
+
case format
|
15
|
+
when :ofx1
|
16
|
+
@builder_class = OFX::Statement::Output::Builder::OFX1
|
17
|
+
when :ofx2
|
18
|
+
@builder_class = OFX::Statement::Output::Builder::OFX2
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def currency
|
23
|
+
@currency ||= 'GBP'
|
24
|
+
end
|
25
|
+
|
26
|
+
def language
|
27
|
+
@language ||= 'ENG'
|
28
|
+
end
|
29
|
+
|
30
|
+
def server_response_time
|
31
|
+
@server_response_time ||= Time.now
|
32
|
+
end
|
33
|
+
|
34
|
+
def <<(transaction)
|
35
|
+
transaction.statement = self
|
36
|
+
transactions << transaction
|
37
|
+
end
|
38
|
+
|
39
|
+
def transactions
|
40
|
+
@transactions ||= []
|
41
|
+
end
|
42
|
+
|
43
|
+
def fitid_for(transaction)
|
44
|
+
index = transactions.index(transaction)
|
45
|
+
Digest::SHA1.hexdigest(self.date.strftime('%Y%m%d') + transaction.date.strftime('%Y%m%d') + index.to_s)
|
46
|
+
end
|
47
|
+
|
48
|
+
def serialise(format = :ofx2)
|
49
|
+
output.new.serialise(self, format)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'ofx/statement/base'
|
2
|
+
require 'ofx/statement/output/credit_card'
|
3
|
+
|
4
|
+
|
5
|
+
module OFX
|
6
|
+
module Statement
|
7
|
+
class CreditCard < OFX::Statement::Base
|
8
|
+
attr_accessor :available_credit
|
9
|
+
|
10
|
+
def output
|
11
|
+
OFX::Statement::Output::CreditCard
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'ofx/statement/base'
|
2
|
+
require 'ofx/statement/output/current_account'
|
3
|
+
|
4
|
+
module OFX
|
5
|
+
module Statement
|
6
|
+
class CurrentAccount < OFX::Statement::Base
|
7
|
+
attr_accessor :sort_code
|
8
|
+
|
9
|
+
def output
|
10
|
+
OFX::Statement::Output::CurrentAccount
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'ofx/statement/output/builder'
|
2
|
+
|
3
|
+
module OFX
|
4
|
+
module Statement
|
5
|
+
module Output
|
6
|
+
class Base
|
7
|
+
class << self
|
8
|
+
# See OFX 2.0.3 spec, section 11.4.3.1 "Transaction types used in <TRNTYPE>"
|
9
|
+
def trntype_hash
|
10
|
+
@trntype_hash ||= {
|
11
|
+
:debit => "DEBIT",
|
12
|
+
:credit => "CREDIT",
|
13
|
+
:interest => "INT",
|
14
|
+
:dividend => "DIV",
|
15
|
+
:fee => "FEE",
|
16
|
+
:service_charge => "SRVCHG",
|
17
|
+
:deposit => "DEP",
|
18
|
+
:atm => "ATM",
|
19
|
+
:point_of_sale => "POS",
|
20
|
+
:transfer => "XFER",
|
21
|
+
:cheque => "CHECK",
|
22
|
+
:check => "CHECK",
|
23
|
+
:payment => "PAYMENT",
|
24
|
+
:cash => "CASH",
|
25
|
+
:direct_deposit => "DIRECTDEP",
|
26
|
+
:direct_debit => "DIRECTDEBIT",
|
27
|
+
:repeating_payment => "REPEATPMT",
|
28
|
+
:standing_order => "REPEATPMT",
|
29
|
+
:other => "OTHER"
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def serialise(statement, format = :ofx2)
|
35
|
+
builder = create_builder(format)
|
36
|
+
builder.ofx_stanza!
|
37
|
+
ofx_block(builder) do |ofx|
|
38
|
+
signon_block(ofx, statement) do |signon|
|
39
|
+
message_set_block(signon) do |message_set|
|
40
|
+
statement_block(message_set, statement) do |stmnt|
|
41
|
+
statement.transactions.each do |transaction|
|
42
|
+
transaction_block(stmnt, transaction)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
builder.target!
|
49
|
+
end
|
50
|
+
|
51
|
+
def fitid_hash
|
52
|
+
@fitid_hash ||= {}
|
53
|
+
end
|
54
|
+
|
55
|
+
def time_to_ofx_dta(timeobj, extended=false)
|
56
|
+
fmt = '%Y%m%d' + (extended ? '%H%M%S' : '')
|
57
|
+
timeobj.strftime(fmt)
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate_fitid(time)
|
61
|
+
date = time_to_ofx_dta(time)
|
62
|
+
suffix = fitid_hash[date].nil? ? 1 : fitid_hash[date] + 1
|
63
|
+
fitid_hash[date] = suffix
|
64
|
+
"#{date}#{suffix}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def generate_trntype(trntype)
|
68
|
+
output = OFX::Statement::Output::Base.trntype_hash[trntype]
|
69
|
+
raise UnknownTrntype, trntype unless output
|
70
|
+
output
|
71
|
+
end
|
72
|
+
|
73
|
+
def ofx_block(node)
|
74
|
+
return node.OFX unless block_given?
|
75
|
+
|
76
|
+
node.OFX { |child| yield(child) }
|
77
|
+
end
|
78
|
+
|
79
|
+
def signon_block(node, statement)
|
80
|
+
node.SIGNONMSGSRSV1 do |signonmsgrsv1|
|
81
|
+
signonmsgrsv1.SONRS do |sonrs|
|
82
|
+
sonrs.STATUS do |status|
|
83
|
+
status.CODE "0"
|
84
|
+
status.SEVERITY "INFO"
|
85
|
+
end
|
86
|
+
sonrs.DTSERVER time_to_ofx_dta(statement.server_response_time, true)
|
87
|
+
sonrs.LANGUAGE statement.language
|
88
|
+
end
|
89
|
+
yield(signonmsgrsv1) if block_given?
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def ledger_balance_block(node, statement)
|
94
|
+
node.LEDGERBAL do |ledgerbal|
|
95
|
+
ledgerbal.BALAMT statement.ledger_balance
|
96
|
+
ledgerbal.DTASOF time_to_ofx_dta(statement.date)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def transaction_list(node, statement)
|
101
|
+
node.BANKTRANLIST do |banktranlist|
|
102
|
+
banktranlist.DTSTART time_to_ofx_dta(statement.start_date)
|
103
|
+
banktranlist.DTEND time_to_ofx_dta(statement.end_date)
|
104
|
+
yield(banktranlist) if block_given?
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def transaction_block(node, transaction)
|
109
|
+
node.STMTTRN do |stmttrn|
|
110
|
+
stmttrn.TRNTYPE generate_trntype(transaction.trntype)
|
111
|
+
stmttrn.DTPOSTED time_to_ofx_dta(transaction.date)
|
112
|
+
stmttrn.TRNAMT transaction.amount
|
113
|
+
stmttrn.FITID transaction.fitid
|
114
|
+
stmttrn.NAME transaction.name
|
115
|
+
stmttrn.MEMO transaction.memo if transaction.has_memo?
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def create_builder(format = :ofx2)
|
120
|
+
case format
|
121
|
+
when :ofx1
|
122
|
+
OFX::Statement::Output::Builder::OFX1.new(:indent => 2)
|
123
|
+
when :ofx2
|
124
|
+
OFX::Statement::Output::Builder::OFX2.new(:indent => 2)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|