egg2ofx 0.1.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 +1 -0
- data/Rakefile +118 -0
- data/bin/egg2ofx +13 -0
- data/lib/egg/account.rb +8 -0
- data/lib/egg/date.rb +10 -0
- data/lib/egg/description.rb +14 -0
- data/lib/egg/money.rb +13 -0
- data/lib/egg/parser.rb +32 -0
- data/lib/egg/parsers/document_parser.rb +42 -0
- data/lib/egg/parsers/recent_transactions_document_parser.rb +19 -0
- data/lib/egg/parsers/recent_transactions_transaction_parser.rb +12 -0
- data/lib/egg/parsers/statement_document_parser.rb +17 -0
- data/lib/egg/parsers/statement_transaction_parser.rb +19 -0
- data/lib/egg/parsers/transaction_parser.rb +29 -0
- data/lib/egg/statement.rb +24 -0
- data/lib/egg/transaction.rb +33 -0
- data/lib/egg.rb +15 -0
- data/lib/ofx.rb +49 -0
- data/test/fixtures/recent_transactions.html +299 -0
- data/test/fixtures/statement.html +429 -0
- data/test/integration/recent_transactions_integration_test.rb +92 -0
- data/test/integration/statement_integration_test.rb +103 -0
- data/test/test_helper.rb +7 -0
- data/test/unit/description_test.rb +27 -0
- data/test/unit/ofx_test.rb +15 -0
- data/test/unit/parser_test.rb +24 -0
- data/test/unit/parsers/recent_transactions_transaction_parser_test.rb +23 -0
- data/test/unit/parsers/statement_transaction_parser_test.rb +23 -0
- metadata +102 -0
data/README
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
This appears to be in a bit of a mess. I seem to remember starting with the egg.html -> egg.ofx conversion and then wanting to do the same with ing-direct. I'm not sure how much of it works anymore.
|
data/Rakefile
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "rake/gempackagetask"
|
3
|
+
require "rake/rdoctask"
|
4
|
+
|
5
|
+
task :default => :test
|
6
|
+
|
7
|
+
require "rake/testtask"
|
8
|
+
Rake::TestTask.new do |t|
|
9
|
+
t.libs << "test"
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
11
|
+
t.verbose = true
|
12
|
+
end
|
13
|
+
|
14
|
+
# This builds the actual gem. For details of what all these options
|
15
|
+
# mean, and other ones you can add, check the documentation here:
|
16
|
+
#
|
17
|
+
# http://rubygems.org/read/chapter/20
|
18
|
+
#
|
19
|
+
spec = Gem::Specification.new do |s|
|
20
|
+
|
21
|
+
# Change these as appropriate
|
22
|
+
s.name = "egg2ofx"
|
23
|
+
s.version = "0.1.1"
|
24
|
+
s.summary = "Converts the html statements and recent transactions from the egg site to ofx"
|
25
|
+
s.author = "Chris Roos"
|
26
|
+
s.email = "chris@seagul.co.uk"
|
27
|
+
s.homepage = "http://chrisroos.co.uk"
|
28
|
+
|
29
|
+
s.has_rdoc = true
|
30
|
+
s.extra_rdoc_files = %w(README)
|
31
|
+
s.rdoc_options = %w(--main README)
|
32
|
+
|
33
|
+
# Add any extra files to include in the gem
|
34
|
+
s.files = %w(Rakefile README) + Dir.glob("{bin,test,lib}/**/*")
|
35
|
+
s.executables = FileList["bin/**"].map { |f| File.basename(f) }
|
36
|
+
|
37
|
+
s.require_paths = ["lib"]
|
38
|
+
|
39
|
+
# If you want to depend on other gems, add them here, along with any
|
40
|
+
# relevant versions
|
41
|
+
# s.add_dependency("some_other_gem", "~> 0.1.0")
|
42
|
+
s.add_dependency 'hpricot'
|
43
|
+
|
44
|
+
# If your tests use any gems, include them here
|
45
|
+
# s.add_development_dependency("mocha")
|
46
|
+
s.add_development_dependency 'mocha'
|
47
|
+
|
48
|
+
# If you want to publish automatically to rubyforge, you'll may need
|
49
|
+
# to tweak this, and the publishing task below too.
|
50
|
+
s.rubyforge_project = "egg2ofx"
|
51
|
+
end
|
52
|
+
|
53
|
+
# This task actually builds the gem. We also regenerate a static
|
54
|
+
# .gemspec file, which is useful if something (i.e. GitHub) will
|
55
|
+
# be automatically building a gem for this project. If you're not
|
56
|
+
# using GitHub, edit as appropriate.
|
57
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
58
|
+
pkg.gem_spec = spec
|
59
|
+
|
60
|
+
# Generate the gemspec file for github.
|
61
|
+
file = File.dirname(__FILE__) + "/#{spec.name}.gemspec"
|
62
|
+
File.open(file, "w") {|f| f << spec.to_ruby }
|
63
|
+
end
|
64
|
+
|
65
|
+
# Generate documentation
|
66
|
+
Rake::RDocTask.new do |rd|
|
67
|
+
rd.main = "README"
|
68
|
+
rd.rdoc_files.include("README", "lib/**/*.rb")
|
69
|
+
rd.rdoc_dir = "rdoc"
|
70
|
+
end
|
71
|
+
|
72
|
+
desc 'Clear out RDoc and generated packages'
|
73
|
+
task :clean => [:clobber_rdoc, :clobber_package] do
|
74
|
+
rm "#{spec.name}.gemspec"
|
75
|
+
end
|
76
|
+
|
77
|
+
# If you want to publish to RubyForge automatically, here's a simple
|
78
|
+
# task to help do that. If you don't, just get rid of this.
|
79
|
+
# Be sure to set up your Rubyforge account details with the Rubyforge
|
80
|
+
# gem; you'll need to run `rubyforge setup` and `rubyforge config` at
|
81
|
+
# the very least.
|
82
|
+
begin
|
83
|
+
require "rake/contrib/sshpublisher"
|
84
|
+
namespace :rubyforge do
|
85
|
+
|
86
|
+
desc "Release gem and RDoc documentation to RubyForge"
|
87
|
+
task :release => ["rubyforge:release:gem", "rubyforge:release:docs"]
|
88
|
+
|
89
|
+
namespace :release do
|
90
|
+
desc "Release a new version of this gem"
|
91
|
+
task :gem => [:package] do
|
92
|
+
require 'rubyforge'
|
93
|
+
rubyforge = RubyForge.new
|
94
|
+
rubyforge.configure
|
95
|
+
rubyforge.login
|
96
|
+
rubyforge.userconfig['release_notes'] = spec.summary
|
97
|
+
path_to_gem = File.join(File.dirname(__FILE__), "pkg", "#{spec.name}-#{spec.version}.gem")
|
98
|
+
puts "Publishing #{spec.name}-#{spec.version.to_s} to Rubyforge..."
|
99
|
+
rubyforge.add_release(spec.rubyforge_project, spec.name, spec.version.to_s, path_to_gem)
|
100
|
+
end
|
101
|
+
|
102
|
+
desc "Publish RDoc to RubyForge."
|
103
|
+
task :docs => [:rdoc] do
|
104
|
+
config = YAML.load(
|
105
|
+
File.read(File.expand_path('~/.rubyforge/user-config.yml'))
|
106
|
+
)
|
107
|
+
|
108
|
+
host = "#{config['username']}@rubyforge.org"
|
109
|
+
remote_dir = "/var/www/gforge-projects/egg2ofx/" # Should be the same as the rubyforge project name
|
110
|
+
local_dir = 'rdoc'
|
111
|
+
|
112
|
+
Rake::SshDirPublisher.new(host, remote_dir, local_dir).upload
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
rescue LoadError
|
117
|
+
puts "Rake SshDirPublisher is unavailable or your rubyforge environment is not configured."
|
118
|
+
end
|
data/bin/egg2ofx
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.join(File.dirname(__FILE__), '..', 'lib', 'egg')
|
4
|
+
|
5
|
+
html_file = ARGV.shift
|
6
|
+
unless html_file and File.exists?(html_file)
|
7
|
+
raise "Usage: egg2ofx path/to/egg-html-file"
|
8
|
+
end
|
9
|
+
|
10
|
+
html = File.open(html_file) { |f| f.read }
|
11
|
+
parser = Egg::Parser.new(html)
|
12
|
+
|
13
|
+
puts parser.to_ofx
|
data/lib/egg/account.rb
ADDED
data/lib/egg/date.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
module Egg
|
2
|
+
class Description
|
3
|
+
attr_reader :payee, :note, :territory
|
4
|
+
def initialize(description)
|
5
|
+
if m = description.match(/(.{23}) (.{13}) (.{2})?/) # territory (last two chars) is not in recent transactions
|
6
|
+
@payee = m[1].strip.squeeze(' ')
|
7
|
+
@note = m[2].strip
|
8
|
+
@territory = m[3].strip if m[3]
|
9
|
+
else
|
10
|
+
@payee = description
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/egg/money.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
module Egg
|
2
|
+
class Money
|
3
|
+
def initialize(money)
|
4
|
+
money = money.sub(/£/, '').gsub(/,/, '')
|
5
|
+
md = money.match(/(\d+\.\d+) ([A-Z]+)/)
|
6
|
+
@money = md ? Float(md[1]) : 0
|
7
|
+
@money = -@money if md && md[2] == 'DR'
|
8
|
+
end
|
9
|
+
def to_f
|
10
|
+
@money
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/lib/egg/parser.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
$: << File.dirname(__FILE__)
|
2
|
+
|
3
|
+
require 'parsers/document_parser'
|
4
|
+
require 'parsers/transaction_parser'
|
5
|
+
require 'parsers/statement_document_parser'
|
6
|
+
require 'parsers/statement_transaction_parser'
|
7
|
+
require 'parsers/recent_transactions_document_parser'
|
8
|
+
require 'parsers/recent_transactions_transaction_parser'
|
9
|
+
|
10
|
+
module Egg
|
11
|
+
|
12
|
+
class Parser
|
13
|
+
|
14
|
+
class UnparsableHtmlError < StandardError; end
|
15
|
+
|
16
|
+
def initialize(html)
|
17
|
+
if html =~ /your egg card statement/i
|
18
|
+
@parser = StatementDocumentParser.new(html)
|
19
|
+
elsif html =~ /egg card recent transactions/i
|
20
|
+
@parser = RecentTransactionsDocumentParser.new(html)
|
21
|
+
else
|
22
|
+
raise UnparsableHtmlError
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_ofx
|
27
|
+
@parser.to_ofx
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Egg
|
2
|
+
|
3
|
+
class DocumentParser
|
4
|
+
|
5
|
+
attr_reader :doc
|
6
|
+
|
7
|
+
def initialize(html)
|
8
|
+
@doc = Hpricot(html)
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_ofx
|
12
|
+
account = Egg::Account.new('GBP', card_number)
|
13
|
+
statement = Egg::Statement.new(statement_date, closing_balance, account)
|
14
|
+
|
15
|
+
each_transaction do |transaction_row|
|
16
|
+
next if transaction_row.skip?
|
17
|
+
transaction = Egg::Transaction.new(transaction_row.date, transaction_row.description, transaction_row.money)
|
18
|
+
statement.add_transaction(transaction)
|
19
|
+
end
|
20
|
+
|
21
|
+
Ofx::Statement.new(statement).to_xml
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def each_transaction
|
27
|
+
(doc/"table#tblTransactionsTable"/"tbody"/"tr").each do |row|
|
28
|
+
yield transaction_parser_class.new(row)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def card_number
|
33
|
+
(doc/"span#lblCardNumber").inner_html
|
34
|
+
end
|
35
|
+
|
36
|
+
def closing_balance
|
37
|
+
((doc/"table#tblTransactionsTable"/"tfoot"/"tr").first/"td").inner_html
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Egg
|
2
|
+
|
3
|
+
class RecentTransactionsDocumentParser < DocumentParser
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def transaction_parser_class
|
8
|
+
RecentTransactionsTransactionParser
|
9
|
+
end
|
10
|
+
|
11
|
+
def statement_date
|
12
|
+
first_transaction_date = (doc/'#tblTransactionsTable'/'tbody'/'tr'/'td.date').first.inner_text
|
13
|
+
last_transaction_date = (doc/'#tblTransactionsTable'/'tbody'/'tr'/'td.date').last.inner_text
|
14
|
+
"#{first_transaction_date} to #{last_transaction_date}"
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Egg
|
2
|
+
|
3
|
+
class StatementDocumentParser < DocumentParser
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def transaction_parser_class
|
8
|
+
StatementTransactionParser
|
9
|
+
end
|
10
|
+
|
11
|
+
def statement_date
|
12
|
+
(doc/'#ctl00_content_eggCardStatements_lstPreviousStatements'/'option[@selected=selected]').first.attributes['value']
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Egg
|
2
|
+
|
3
|
+
class StatementTransactionParser < TransactionParser
|
4
|
+
|
5
|
+
def description
|
6
|
+
description = super
|
7
|
+
if row.next_sibling && (row.next_sibling/"td.date").inner_text == '' && (row.next_sibling/"td.description").inner_text != ''
|
8
|
+
description += ' / ' + (row.next_sibling/"td.description").inner_text
|
9
|
+
end
|
10
|
+
description
|
11
|
+
end
|
12
|
+
|
13
|
+
def skip?
|
14
|
+
date == '' or description == 'OPENING BALANCE'
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Egg
|
2
|
+
|
3
|
+
class TransactionParser
|
4
|
+
|
5
|
+
attr_reader :row
|
6
|
+
|
7
|
+
def initialize(row)
|
8
|
+
@row = row
|
9
|
+
end
|
10
|
+
|
11
|
+
def skip?
|
12
|
+
false
|
13
|
+
end
|
14
|
+
|
15
|
+
def money
|
16
|
+
(row/"td.money").inner_text.sub(/^\?/, '').sub(/^£/, '')
|
17
|
+
end
|
18
|
+
|
19
|
+
def date
|
20
|
+
(row/"td.date").inner_text
|
21
|
+
end
|
22
|
+
|
23
|
+
def description
|
24
|
+
(row/"td.description").inner_text
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Egg
|
2
|
+
class Statement
|
3
|
+
def initialize(statement_date, closing_balance, account)
|
4
|
+
from_date, to_date = statement_date.split(' to ')
|
5
|
+
@from_date = Date.build(from_date)
|
6
|
+
@to_date = Date.build(to_date)
|
7
|
+
@closing_balance = Money.new(closing_balance).to_f
|
8
|
+
@account = account
|
9
|
+
@transactions = []
|
10
|
+
end
|
11
|
+
attr_reader :from_date, :to_date, :closing_balance, :transactions
|
12
|
+
def add_transaction(transaction)
|
13
|
+
transaction.date = from_date unless transaction.date
|
14
|
+
@transactions << transaction
|
15
|
+
end
|
16
|
+
def method_missing(sym, *args, &blk)
|
17
|
+
if account_method = sym.to_s[/^account_(.+)/, 1]
|
18
|
+
@account.__send__(account_method, *args, &blk)
|
19
|
+
else
|
20
|
+
super(sym, *args, &blk)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module Egg
|
4
|
+
class Transaction
|
5
|
+
def initialize(date, description, money)
|
6
|
+
@date = Date.build(date)
|
7
|
+
@raw_description = description
|
8
|
+
@description = Description.new(description)
|
9
|
+
@amount = Money.new(money).to_f
|
10
|
+
@ofx_id = nil
|
11
|
+
end
|
12
|
+
attr_reader :date, :amount
|
13
|
+
def payee
|
14
|
+
@description.payee
|
15
|
+
end
|
16
|
+
def note
|
17
|
+
@description.note
|
18
|
+
end
|
19
|
+
def ofx_id
|
20
|
+
@ofx_id ||= Digest::MD5.hexdigest(to_s)
|
21
|
+
end
|
22
|
+
def to_s
|
23
|
+
[date, @raw_description, amount].join(', ')
|
24
|
+
end
|
25
|
+
def type
|
26
|
+
if payee =~ /INTEREST/
|
27
|
+
'INT'
|
28
|
+
else
|
29
|
+
(amount < 0) ? 'DEBIT' : 'CREDIT'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/lib/egg.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
$: << File.dirname(__FILE__)
|
2
|
+
|
3
|
+
require 'hpricot'
|
4
|
+
|
5
|
+
require 'egg/date'
|
6
|
+
|
7
|
+
require 'egg/statement'
|
8
|
+
require 'egg/account'
|
9
|
+
require 'egg/transaction'
|
10
|
+
require 'egg/money'
|
11
|
+
require 'egg/description'
|
12
|
+
|
13
|
+
require 'egg/parser'
|
14
|
+
|
15
|
+
require 'ofx'
|
data/lib/ofx.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'builder'
|
2
|
+
|
3
|
+
module Ofx
|
4
|
+
class Statement
|
5
|
+
def initialize(statement)
|
6
|
+
@statement = statement
|
7
|
+
@xml = nil
|
8
|
+
end
|
9
|
+
def to_xml
|
10
|
+
@xml ||= (
|
11
|
+
buffer = ''
|
12
|
+
builder = Builder::XmlMarkup.new(:target => buffer, :indent => 2)
|
13
|
+
builder.instruct!
|
14
|
+
builder << "<?OFX OFXHEADER=\"200\" VERSION=\"200\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>\n"
|
15
|
+
builder.OFX do
|
16
|
+
builder.CREDITCARDMSGSRSV1 do
|
17
|
+
builder.CCSTMTTRNRS do
|
18
|
+
builder.CCSTMTRS do
|
19
|
+
builder.CURDEF @statement.account_currency
|
20
|
+
builder.CCACCTFROM do
|
21
|
+
builder.ACCTID @statement.account_number
|
22
|
+
end
|
23
|
+
builder.BANKTRANLIST do
|
24
|
+
builder.DTSTART @statement.from_date
|
25
|
+
builder.DTEND @statement.to_date
|
26
|
+
@statement.transactions.each do |transaction|
|
27
|
+
builder.STMTTRN do
|
28
|
+
builder.TRNTYPE transaction.type
|
29
|
+
builder.DTPOSTED transaction.date
|
30
|
+
builder.NAME transaction.payee
|
31
|
+
builder.MEMO transaction.note
|
32
|
+
builder.TRNAMT transaction.amount
|
33
|
+
builder.FITID transaction.ofx_id
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
builder.LEDGERBAL do
|
38
|
+
builder.BALAMT @statement.closing_balance
|
39
|
+
builder.DTASOF @statement.to_date
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
buffer
|
46
|
+
)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|