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.
Files changed (51) hide show
  1. data/README.rdoc +38 -0
  2. data/Rakefile +141 -0
  3. data/bin/coop_to_ofx +60 -0
  4. data/lib/coop_scraper/base.rb +8 -0
  5. data/lib/coop_scraper/credit_card.rb +94 -0
  6. data/lib/coop_scraper/current_account.rb +92 -0
  7. data/lib/coop_scraper/version.rb +12 -0
  8. data/lib/coop_scraper.rb +2 -0
  9. data/lib/ofx/statement/base.rb +53 -0
  10. data/lib/ofx/statement/credit_card.rb +15 -0
  11. data/lib/ofx/statement/current_account.rb +14 -0
  12. data/lib/ofx/statement/output/base.rb +131 -0
  13. data/lib/ofx/statement/output/builder.rb +76 -0
  14. data/lib/ofx/statement/output/credit_card.rb +31 -0
  15. data/lib/ofx/statement/output/current_account.rb +29 -0
  16. data/lib/ofx/statement/transaction.rb +52 -0
  17. data/lib/ofx/statement.rb +3 -0
  18. data/lib/ofx.rb +1 -0
  19. data/spec/coop_scraper/base_spec.rb +15 -0
  20. data/spec/coop_scraper/credit_card_spec.rb +229 -0
  21. data/spec/coop_scraper/current_account_spec.rb +154 -0
  22. data/spec/fixtures/credit_card/cc_statement_fixture.html +927 -0
  23. data/spec/fixtures/credit_card/foreign_transaction_fixture.html +447 -0
  24. data/spec/fixtures/credit_card/interest_transaction_fixture.html +438 -0
  25. data/spec/fixtures/credit_card/maybe.txt +43 -0
  26. data/spec/fixtures/credit_card/merchandise_interest_fixture.html +0 -0
  27. data/spec/fixtures/credit_card/normal_transaction_fixture.html +439 -0
  28. data/spec/fixtures/credit_card/overlimit_charge_fixture.html +446 -0
  29. data/spec/fixtures/credit_card/payment_in_transaction_fixture.html +439 -0
  30. data/spec/fixtures/credit_card/simple_cc_statement.ofx +43 -0
  31. data/spec/fixtures/credit_card/statement_with_interest_line_fixture.html +452 -0
  32. data/spec/fixtures/current_account/cash_point_transaction_fixture.html +372 -0
  33. data/spec/fixtures/current_account/current_account_fixture.html +420 -0
  34. data/spec/fixtures/current_account/current_account_fixture.ofx +83 -0
  35. data/spec/fixtures/current_account/debit_interest_transaction_fixture.html +372 -0
  36. data/spec/fixtures/current_account/no_transactions_fixture.html +364 -0
  37. data/spec/fixtures/current_account/normal_transaction_fixture.html +372 -0
  38. data/spec/fixtures/current_account/payment_in_transaction_fixture.html +372 -0
  39. data/spec/fixtures/current_account/service_charge_transaction_fixture.html +372 -0
  40. data/spec/fixtures/current_account/transfer_transaction_fixture.html +372 -0
  41. data/spec/ofx/statement/base_spec.rb +116 -0
  42. data/spec/ofx/statement/credit_card_spec.rb +20 -0
  43. data/spec/ofx/statement/current_account_spec.rb +20 -0
  44. data/spec/ofx/statement/output/base_spec.rb +249 -0
  45. data/spec/ofx/statement/output/builder_spec.rb +38 -0
  46. data/spec/ofx/statement/output/credit_card_spec.rb +84 -0
  47. data/spec/ofx/statement/output/current_account_spec.rb +81 -0
  48. data/spec/ofx/statement/transaction_spec.rb +76 -0
  49. data/spec/spec.opts +2 -0
  50. data/spec/spec_helper.rb +36 -0
  51. 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,8 @@
1
+ module CoopScraper
2
+ module Base
3
+ def coop_date_to_time(coop_date)
4
+ day, month, year = coop_date.match(/([0-9]{2})\/([0-9]{2})\/([0-9]{4})/).captures
5
+ Time.utc(year, month, day)
6
+ end
7
+ end
8
+ end
@@ -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
@@ -0,0 +1,12 @@
1
+ module CoopScraper
2
+ def self.Version
3
+ CoopScraper::Version::FULL
4
+ end
5
+
6
+ module Version
7
+ MAJOR = 1
8
+ MINOR = 0
9
+ POINT = 1
10
+ FULL = [CoopScraper::Version::MAJOR, CoopScraper::Version::MINOR, CoopScraper::Version::POINT].join('.')
11
+ end
12
+ end
@@ -0,0 +1,2 @@
1
+ require 'coop_scraper/credit_card'
2
+ require 'coop_scraper/current_account'
@@ -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
+