coop_to_ofx 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+