reckon 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+
21
+ ## PROJECT::SPECIFIC
22
+ .idea
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Andrew Cantino
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,17 @@
1
+ = reckon
2
+
3
+ Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.
4
+
5
+ == Note on Patches/Pull Requests
6
+
7
+ * Fork the project.
8
+ * Make your feature addition or bug fix.
9
+ * Add tests for it. This is important so I don't break it in a
10
+ future version unintentionally.
11
+ * Commit, do not mess with rakefile, version, or history.
12
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
13
+ * Send me a pull request. Bonus points for topic branches.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2010 Andrew Cantino. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "reckon"
8
+ gem.summary = %Q{Utility for interactively converting and labeling CSV files for the Ledger accounting tool.}
9
+ gem.description = %Q{Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.}
10
+ gem.email = "andrew@iterationlabs.com"
11
+ gem.homepage = "http://github.com/iterationlabs/reckon"
12
+ gem.authors = ["Andrew Cantino"]
13
+ gem.add_development_dependency "rspec", ">= 1.2.9"
14
+ gem.add_dependency('fastercsv', '>= 1.5.1')
15
+ gem.add_dependency('highline', '>= 1.5.2')
16
+ gem.add_dependency('terminal-table', '>= 1.4.2')
17
+ gem.executables << 'reckon'
18
+ end
19
+ Jeweler::GemcutterTasks.new
20
+ rescue LoadError
21
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
22
+ end
23
+
24
+ require 'spec/rake/spectask'
25
+ Spec::Rake::SpecTask.new(:spec) do |spec|
26
+ spec.libs << 'lib' << 'spec'
27
+ spec.spec_files = FileList['spec/**/*_spec.rb']
28
+ end
29
+
30
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
31
+ spec.libs << 'lib' << 'spec'
32
+ spec.pattern = 'spec/**/*_spec.rb'
33
+ spec.rcov = true
34
+ end
35
+
36
+ task :spec => :check_dependencies
37
+
38
+ task :default => :spec
39
+
40
+ require 'rake/rdoctask'
41
+ Rake::RDocTask.new do |rdoc|
42
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
43
+
44
+ rdoc.rdoc_dir = 'rdoc'
45
+ rdoc.title = "reckon #{version}"
46
+ rdoc.rdoc_files.include('README*')
47
+ rdoc.rdoc_files.include('lib/**/*.rb')
48
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
data/bin/reckon ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'reckon'
5
+
6
+ options = Reckon::App.parse_opts
7
+ reckon = Reckon::App.new(options)
8
+
9
+ if options[:print_table]
10
+ reckon.output_table
11
+ exit
12
+ end
13
+
14
+ if !reckon.money_column_indices
15
+ puts "I was unable to determine either a single or a pair of combined columns to use as the money column."
16
+ exit
17
+ end
18
+
19
+ reckon.walk_backwards
data/lib/reckon.rb ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'fastercsv'
5
+ require 'highline/import'
6
+ require 'optparse'
7
+ require 'time'
8
+ require 'terminal-table'
9
+
10
+ require File.expand_path(File.join(File.dirname(__FILE__), "reckon", "app"))
11
+ require File.expand_path(File.join(File.dirname(__FILE__), "reckon", "ledger_parser"))
12
+
data/lib/reckon/app.rb ADDED
@@ -0,0 +1,364 @@
1
+ module Reckon
2
+ class App
3
+ VERSION = "Reckon 0.1"
4
+
5
+ attr_accessor :options, :csv_data, :accounts, :tokens, :money_column_indices, :date_column_index, :description_column_indices, :seen
6
+
7
+ def initialize(options = {})
8
+ self.options = options
9
+ self.tokens = {}
10
+ self.accounts = {}
11
+ self.seen = {}
12
+ learn!
13
+ parse
14
+ filter_csv
15
+ detect_columns
16
+ end
17
+
18
+ def filter_csv
19
+ if options[:ignore_columns]
20
+ new_columns = []
21
+ columns.each_with_index do |column, index|
22
+ new_columns << column unless options[:ignore_columns].include?(index + 1)
23
+ end
24
+ @columns = new_columns
25
+ end
26
+ end
27
+
28
+ def learn_from(ledger)
29
+ LedgerParser.new(ledger).entries.each do |entry|
30
+ entry[:accounts].each do |account|
31
+ learn_about_account( account[:name],
32
+ [entry[:desc], account[:amount]].join(" ") ) unless account[:name] == options[:bank_account]
33
+ seen[entry[:date]] ||= {}
34
+ seen[entry[:date]][pretty_money(account[:amount])] = true
35
+ end
36
+ end
37
+ end
38
+
39
+ def already_seen?(row)
40
+ seen[row[:pretty_date]] && seen[row[:pretty_date]][row[:pretty_money]]
41
+ end
42
+
43
+ def learn!
44
+ if options[:existing_ledger_file]
45
+ fail "#{options[:existing_ledger_file]} doesn't exist!" unless File.exists?(options[:existing_ledger_file])
46
+ ledger_data = File.read(options[:existing_ledger_file])
47
+ learn_from(ledger_data)
48
+ end
49
+ end
50
+
51
+ def learn_about_account(account, data)
52
+ accounts[account] ||= 0
53
+ tokenize(data).each do |token|
54
+ tokens[token] ||= {}
55
+ tokens[token][account] ||= 0
56
+ tokens[token][account] += 1
57
+ accounts[account] += 1
58
+ end
59
+ end
60
+
61
+ def tokenize(str)
62
+ str.downcase.split(/[\s\-]/)
63
+ end
64
+
65
+ def walk_backwards
66
+ seen_anything_new = false
67
+ each_row_backwards do |row|
68
+ puts Terminal::Table.new(:rows => [ [ row[:pretty_date], row[:pretty_money], row[:description] ] ])
69
+
70
+ if already_seen?(row)
71
+ puts "NOTE: This row is very similar to a previous one!"
72
+ if !seen_anything_new
73
+ puts "Skipping..."
74
+ next
75
+ end
76
+ else
77
+ seen_anything_new = true
78
+ end
79
+
80
+ ledger = if row[:money] > 0
81
+ out_of_account = ask("Which account provided this income? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
82
+ finish if out_of_account == "quit" || out_of_account == "q"
83
+ if out_of_account == "skip" || out_of_account == "s"
84
+ puts "Skipping"
85
+ next
86
+ end
87
+
88
+ ledger_format( row,
89
+ [options[:bank_account], row[:pretty_money]],
90
+ [out_of_account, row[:pretty_money_negated]] )
91
+ else
92
+ into_account = ask("To which account did this money go? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
93
+ finish if into_account == "quit" || into_account == 'q'
94
+ if into_account == "skip" || into_account == 's'
95
+ puts "Skipping"
96
+ next
97
+ end
98
+
99
+ ledger_format( row,
100
+ [into_account, row[:pretty_money_negated]],
101
+ [options[:bank_account], row[:pretty_money]] )
102
+ end
103
+
104
+ learn_from(ledger)
105
+ output(ledger)
106
+ end
107
+ end
108
+
109
+ def finish
110
+ options[:output_file].close unless options[:output_file] == STDOUT
111
+ puts "Exiting."
112
+ exit
113
+ end
114
+
115
+ def output(ledger_line)
116
+ options[:output_file].puts ledger_line
117
+ options[:output_file].flush
118
+ end
119
+
120
+ def guess_account(row)
121
+ query_tokens = tokenize(row[:description])
122
+
123
+ search_vector = []
124
+ account_vectors = {}
125
+
126
+ query_tokens.each do |token|
127
+ idf = Math.log((accounts.keys.length + 1) / ((tokens[token] || {}).keys.length.to_f + 1))
128
+ tf = 1.0 / query_tokens.length.to_f
129
+ search_vector << tf*idf
130
+
131
+ accounts.each do |account, total_terms|
132
+ tf = (tokens[token] && tokens[token][account]) ? tokens[token][account] / total_terms.to_f : 0
133
+ account_vectors[account] ||= []
134
+ account_vectors[account] << tf*idf
135
+ end
136
+ end
137
+
138
+ # Should I normalize the vectors? Probably unnecessary due to tf-idf and short documents.
139
+
140
+ account_vectors = account_vectors.to_a.map do |account, account_vector|
141
+ { :cosine => (0...account_vector.length).to_a.inject(0) { |m, i| m + search_vector[i] * account_vector[i] },
142
+ :account => account }
143
+ end
144
+
145
+ account_vectors.sort! {|a, b| b[:cosine] <=> a[:cosine] }
146
+ account_vectors.first && account_vectors.first[:account]
147
+ end
148
+
149
+ def ledger_format(row, line1, line2)
150
+ out = "#{row[:pretty_date]}\t#{row[:description]}\n"
151
+ out += "\t#{line1.first}\t\t\t\t\t#{line1.last}\n"
152
+ out += "\t#{line2.first}\t\t\t\t\t#{line2.last}\n\n"
153
+ out
154
+ end
155
+
156
+ def money_for(index)
157
+ value = money_column_indices.inject("") { |m, i| m + columns[i][index] }
158
+ cleaned_value = value.gsub(/[^\d\.]/, '').to_f
159
+ cleaned_value *= -1 if value =~ /[\(\-]/
160
+ cleaned_value
161
+ end
162
+
163
+ def pretty_money_for(index, negate = false)
164
+ pretty_money(money_for(index), negate)
165
+ end
166
+
167
+ def pretty_money(amount, negate = false)
168
+ (amount >= 0 ? " " : "") + sprintf("%0.2f", amount * (negate ? -1 : 1)).gsub(/^((\-)|)(?=\d)/, '\1$')
169
+ end
170
+
171
+ def date_for(index)
172
+ value = columns[date_column_index][index]
173
+ value = [$1, $2, $3].join("/") if value =~ /^(\d{4})(\d{2})(\d{2})\d+\[\d+\:GMT\]$/ # chase format
174
+ Time.parse(value)
175
+ end
176
+
177
+ def pretty_date_for(index)
178
+ date_for(index).strftime("%Y/%m/%d")
179
+ end
180
+
181
+ def description_for(index)
182
+ description_column_indices.map { |i| columns[i][index] }.join("; ").squeeze(" ")
183
+ end
184
+
185
+ def output_table
186
+ output = Terminal::Table.new do |t|
187
+ t.headings = 'Date', 'Amount', 'Description'
188
+ each_row_backwards do |row|
189
+ t << [ row[:pretty_date], row[:pretty_money], row[:description] ]
190
+ end
191
+ end
192
+ puts output
193
+ end
194
+
195
+ def evaluate_columns(cols)
196
+ results = []
197
+ found_likely_money_column = false
198
+ cols.each_with_index do |column, index|
199
+ money_score = date_score = possible_neg_money_count = possible_pos_money_count = 0
200
+ column.each do |entry|
201
+ entry = entry.strip
202
+ money_score += 10 if entry[/^[\-\+\(]{0,2}\$/]
203
+ money_score += entry.gsub(/[^\d\.\-\+,\(\)]/, '').length
204
+ money_score -= 100 if entry.length > 17
205
+ money_score -= 20 if entry !~ /^[\$\+\.\-,\d\(\)]+$/
206
+ possible_neg_money_count += 1 if entry =~ /^\$?[\-\(]\$?\d+/
207
+ possible_pos_money_count += 1 if entry =~ /^\+?\$?\+?\d+/
208
+ date_score += 10 if entry =~ /^[\-\/\.\d:\[\]]+$/
209
+ date_score += entry.gsub(/[^\-\/\.\d:\[\]]/, '').length
210
+ date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length * 2
211
+ date_score += 30 if entry =~ /^\d+[:\/\.]\d+[:\/\.]\d+([ :]\d+[:\/\.]\d+)?$/
212
+ date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
213
+ end
214
+
215
+ if possible_neg_money_count > (column.length / 5.0) && possible_pos_money_count > (column.length / 5.0)
216
+ money_score += 10 * column.length
217
+ found_likely_money_column = true
218
+ end
219
+
220
+ results << { :index => index, :money_score => money_score, :date_score => date_score }
221
+ end
222
+
223
+ return [results, found_likely_money_column]
224
+ end
225
+
226
+ def merge_columns(a, b)
227
+ output_columns = []
228
+ columns.each_with_index do |column, index|
229
+ if index == a
230
+ new_column = []
231
+ column.each_with_index do |row, row_index|
232
+ new_column << row + " " + columns[b][row_index]
233
+ end
234
+ output_columns << new_column
235
+ elsif index == b
236
+ # skip
237
+ else
238
+ output_columns << column
239
+ end
240
+ end
241
+ output_columns
242
+ end
243
+
244
+ def detect_columns
245
+ results, found_likely_money_column = evaluate_columns(columns)
246
+
247
+ if found_likely_money_column
248
+ self.money_column_indices = [ results.sort { |a, b| b[:money_score] <=> a[:money_score] }.first[:index] ]
249
+ else
250
+ 0.upto(columns.length - 2) do |i|
251
+ _, found_likely_money_column = evaluate_columns(merge_columns(i, i+1))
252
+
253
+ if found_likely_money_column
254
+ self.money_column_indices = [ i, i+1 ]
255
+ break
256
+ end
257
+ end
258
+ end
259
+
260
+ if money_column_indices
261
+ results.reject! {|i| money_column_indices.include?(i[:index]) }
262
+ self.date_column_index = results.sort { |a, b| b[:date_score] <=> a[:date_score] }.first[:index]
263
+ results.reject! {|i| i[:index] == date_column_index }
264
+
265
+ self.description_column_indices = results.map { |i| i[:index] }
266
+ end
267
+ end
268
+
269
+ def each_row_backwards
270
+ rows = []
271
+ (0...columns.first.length).to_a.each do |index|
272
+ rows << { :date => date_for(index), :pretty_date => pretty_date_for(index),
273
+ :pretty_money => pretty_money_for(index), :pretty_money_negated => pretty_money_for(index, :negate),
274
+ :money => money_for(index), :description => description_for(index) }
275
+ end
276
+ rows.sort { |a, b| a[:date] <=> b[:date] }.each do |row|
277
+ yield row
278
+ end
279
+ end
280
+
281
+ def columns
282
+ @columns ||= begin
283
+ last_row_length = nil
284
+ csv_data.inject([]) do |memo, row|
285
+ fail "Input CSV must have consistent row lengths." if last_row_length && row.length != last_row_length
286
+ unless row.all? { |i| i.nil? || i.length == 0 }
287
+ row.each_with_index do |entry, index|
288
+ memo[index] ||= []
289
+ memo[index] << entry.strip
290
+ end
291
+ last_row_length = row.length
292
+ end
293
+ memo
294
+ end
295
+ end
296
+ end
297
+
298
+ def parse
299
+ self.csv_data = FasterCSV.parse(options[:string] || File.read(options[:file]))
300
+ end
301
+
302
+ def self.parse_opts(args = ARGV)
303
+ options = { :output_file => STDOUT }
304
+ parser = OptionParser.new do |opts|
305
+ opts.banner = "Usage: Reckon.rb [options]"
306
+ opts.separator ""
307
+
308
+ opts.on("-f", "--file FILE", "The CSV file to parse") do |file|
309
+ options[:file] = file
310
+ end
311
+
312
+ opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
313
+ options[:verbose] = v
314
+ end
315
+
316
+ opts.on("-p", "--print-table", "Print out the parsed CSV in table form") do |p|
317
+ options[:print_table] = p
318
+ end
319
+
320
+ opts.on("-o", "--output-file FILE", "The ledger file to append to") do |o|
321
+ options[:output_file] = File.open(o, 'a')
322
+ end
323
+
324
+ opts.on("-l", "--learn-from FILE", "An existing ledger file to learn accounts from") do |l|
325
+ options[:existing_ledger_file] = l
326
+ end
327
+
328
+ opts.on("", "--ignore-columns 1,2,5", "Columns to ignore in the CSV file - the first column is column 1") do |ignore|
329
+ options[:ignore_columns] = ignore.split(",").map { |i| i.to_i }
330
+ end
331
+
332
+ opts.on_tail("-h", "--help", "Show this message") do
333
+ puts opts
334
+ exit
335
+ end
336
+
337
+ opts.on_tail("--version", "Show version") do
338
+ puts VERSION
339
+ exit
340
+ end
341
+
342
+ opts.parse!(args)
343
+ end
344
+
345
+ unless options[:file]
346
+ options[:file] = ask("What CSV file should I parse? ")
347
+ unless options[:file].length > 0
348
+ puts "\nYou must provide a CSV file to parse.\n"
349
+ puts parser
350
+ exit
351
+ end
352
+ end
353
+
354
+ unless options[:bank_account]
355
+ options[:bank_account] = ask("What is the account name of this bank account in Ledger? ") do |q|
356
+ q.validate = /^.{2,}$/
357
+ q.default = "Assets:Bank:Checking"
358
+ end
359
+ end
360
+
361
+ options
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+
5
+ module Reckon
6
+ class LedgerParser
7
+
8
+ attr_accessor :entries
9
+
10
+ def initialize(ledger, options = {})
11
+ @entries = []
12
+ parse(ledger)
13
+ end
14
+
15
+ def parse(ledger)
16
+ @entries = []
17
+ date = desc = nil
18
+ accounts = []
19
+ ledger.strip.split("\n").each do |entry|
20
+ next if entry =~ /^\s*$/ || entry =~ /^[^ \t\d]/
21
+ if entry =~ /^([\d\/]+)(\=[\d\/]+)?(\s+[\*!]?\s*.*?)$/
22
+ @entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
23
+ date = $1
24
+ desc = $3
25
+ accounts = []
26
+ elsif date && entry =~ /^\s+([a-z\s:_\-]+)(\s*$|(\s+[\$\.,\-\d\+]+)($|\s+($|[^\$\.,\-\d\+])))/i
27
+ accounts << { :name => $1.strip, :amount => clean_money($3) }
28
+ else
29
+ @entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
30
+ date = desc = nil
31
+ accounts = []
32
+ end
33
+ end
34
+ @entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
35
+ end
36
+
37
+ def balance(accounts)
38
+ if accounts.any? { |i| i[:amount].nil? }
39
+ sum = accounts.inject(0) {|m, account| m + (account[:amount] || 0) }
40
+ count = 0
41
+ accounts.each do |account|
42
+ if account[:amount].nil?
43
+ count += 1
44
+ account[:amount] = 0 - sum
45
+ end
46
+ end
47
+ if count > 1
48
+ puts "Warning: unparsable entry due to more than one missing money value."
49
+ p accounts
50
+ puts
51
+ end
52
+ end
53
+
54
+ accounts
55
+ end
56
+
57
+ def clean_money(money)
58
+ return nil if money.nil? || money.length == 0
59
+ money.gsub(/[\$,]/, '').to_f
60
+ end
61
+ end
62
+ end
data/reckon.gemspec ADDED
@@ -0,0 +1,70 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{reckon}
8
+ s.version = "0.2.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Andrew Cantino"]
12
+ s.date = %q{2010-11-06}
13
+ s.description = %q{Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.}
14
+ s.email = %q{andrew@iterationlabs.com}
15
+ s.executables = ["reckon", "reckon"]
16
+ s.extra_rdoc_files = [
17
+ "LICENSE",
18
+ "README.rdoc"
19
+ ]
20
+ s.files = [
21
+ ".document",
22
+ ".gitignore",
23
+ "LICENSE",
24
+ "README.rdoc",
25
+ "Rakefile",
26
+ "VERSION",
27
+ "bin/reckon",
28
+ "lib/reckon.rb",
29
+ "lib/reckon/app.rb",
30
+ "lib/reckon/ledger_parser.rb",
31
+ "reckon.gemspec",
32
+ "spec/reckon/app_spec.rb",
33
+ "spec/reckon/ledger_parser_spec.rb",
34
+ "spec/spec.opts",
35
+ "spec/spec_helper.rb"
36
+ ]
37
+ s.homepage = %q{http://github.com/iterationlabs/reckon}
38
+ s.rdoc_options = ["--charset=UTF-8"]
39
+ s.require_paths = ["lib"]
40
+ s.rubygems_version = %q{1.3.7}
41
+ s.summary = %q{Utility for interactively converting and labeling CSV files for the Ledger accounting tool.}
42
+ s.test_files = [
43
+ "spec/reckon/app_spec.rb",
44
+ "spec/reckon/ledger_parser_spec.rb",
45
+ "spec/spec_helper.rb"
46
+ ]
47
+
48
+ if s.respond_to? :specification_version then
49
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
50
+ s.specification_version = 3
51
+
52
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
53
+ s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
54
+ s.add_runtime_dependency(%q<fastercsv>, [">= 1.5.1"])
55
+ s.add_runtime_dependency(%q<highline>, [">= 1.5.2"])
56
+ s.add_runtime_dependency(%q<terminal-table>, [">= 1.4.2"])
57
+ else
58
+ s.add_dependency(%q<rspec>, [">= 1.2.9"])
59
+ s.add_dependency(%q<fastercsv>, [">= 1.5.1"])
60
+ s.add_dependency(%q<highline>, [">= 1.5.2"])
61
+ s.add_dependency(%q<terminal-table>, [">= 1.4.2"])
62
+ end
63
+ else
64
+ s.add_dependency(%q<rspec>, [">= 1.2.9"])
65
+ s.add_dependency(%q<fastercsv>, [">= 1.5.1"])
66
+ s.add_dependency(%q<highline>, [">= 1.5.2"])
67
+ s.add_dependency(%q<terminal-table>, [">= 1.4.2"])
68
+ end
69
+ end
70
+
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.dirname(__FILE__) + '/../spec_helper'
4
+ require 'rubygems'
5
+ require 'reckon'
6
+
7
+ describe Reckon::App do
8
+ before do
9
+ @chase = Reckon::App.new(:string => CHASE_CSV)
10
+ @some_other_bank = Reckon::App.new(:string => SOME_OTHER_CSV)
11
+ @two_money_columns = Reckon::App.new(:string => TWO_MONEY_COLUMNS_BANK)
12
+ @simple_csv = Reckon::App.new(:string => SIMPLE_CSV)
13
+ end
14
+
15
+ describe "columns" do
16
+ it "should return the csv transposed" do
17
+ @simple_csv.columns.should == [["entry1", "entry4"], ["entry2", "entry5"], ["entry3", "entry6"]]
18
+ @chase.columns.length.should == 4
19
+ end
20
+ end
21
+
22
+ describe "detect_columns" do
23
+ it "should detect the money column" do
24
+ @chase.money_column_indices.should == [3]
25
+ @some_other_bank.money_column_indices.should == [3]
26
+ @two_money_columns.money_column_indices.should == [3, 4]
27
+ end
28
+
29
+ it "should detect the date column" do
30
+ @chase.date_column_index.should == 1
31
+ @some_other_bank.date_column_index.should == 1
32
+ @two_money_columns.date_column_index.should == 0
33
+ end
34
+
35
+ it "should consider all other columns to be description columns" do
36
+ @chase.description_column_indices.should == [0, 2]
37
+ @some_other_bank.description_column_indices.should == [0, 2]
38
+ @two_money_columns.description_column_indices.should == [1, 2, 5]
39
+ end
40
+ end
41
+
42
+ describe "each_index_backwards" do
43
+ it "should hit every index" do
44
+ count = 0
45
+ @chase.each_row_backwards { count += 1}
46
+ count.should == 9
47
+ end
48
+ end
49
+
50
+ describe "money_for" do
51
+ it "should return the appropriate fields" do
52
+ @chase.money_for(1).should == -20
53
+ @chase.money_for(4).should == 1558.52
54
+ @chase.money_for(7).should == -116.22
55
+ @some_other_bank.money_for(1).should == -20
56
+ @some_other_bank.money_for(4).should == 1558.52
57
+ @some_other_bank.money_for(7).should == -116.22
58
+ @two_money_columns.money_for(0).should == -76
59
+ @two_money_columns.money_for(1).should == 327.49
60
+ @two_money_columns.money_for(2).should == -800
61
+ @two_money_columns.money_for(3).should == -88.55
62
+ @two_money_columns.money_for(4).should == 88.55
63
+ end
64
+ end
65
+
66
+ describe "date_for" do
67
+ it "should return a parsed date object" do
68
+ @chase.date_for(1).should == Time.parse("2009/12/24")
69
+ @some_other_bank.date_for(1).should == Time.parse("2010/12/24")
70
+ end
71
+ end
72
+
73
+ describe "description_for" do
74
+ it "should return the combined fields that are not money for date fields" do
75
+ @chase.description_for(1).should == "CHECK; CHECK 2656"
76
+ @chase.description_for(7).should == "CREDIT; PAYPAL TRANSFER PPD ID: PAYPALSDSL"
77
+ end
78
+ end
79
+
80
+ describe "pretty_money_for" do
81
+ it "work with negative and positive numbers" do
82
+ @some_other_bank.pretty_money_for(1).should == "-$20.00"
83
+ @some_other_bank.pretty_money_for(4).should == " $1558.52"
84
+ @some_other_bank.pretty_money_for(7).should == "-$116.22"
85
+ @some_other_bank.pretty_money_for(5).should == " $0.23"
86
+ @some_other_bank.pretty_money_for(6).should == "-$0.96"
87
+ end
88
+ end
89
+
90
+ describe "merge_columns" do
91
+ it "should work on adjacent columns" do
92
+ @simple_csv.merge_columns(0,1).should == [["entry1 entry2", "entry4 entry5"], ["entry3", "entry6"]]
93
+ end
94
+
95
+ it "should work on non-adjacent columns" do
96
+ @simple_csv.merge_columns(0,2).should == [["entry1 entry3", "entry4 entry6"], ["entry2", "entry5"]]
97
+ end
98
+ end
99
+
100
+
101
+
102
+ # Data
103
+
104
+ SIMPLE_CSV = "entry1,entry2,entry3\nentry4,entry5,entry6"
105
+
106
+ CHASE_CSV = (<<-CSV).strip
107
+ DEBIT,20091224120000[0:GMT],"HOST 037196321563 MO 12/22SLICEHOST",-85.00
108
+ CHECK,20091224120000[0:GMT],"CHECK 2656",-20.00
109
+ DEBIT,20091224120000[0:GMT],"GITHUB 041287430274 CA 12/22GITHUB 04",-7.00
110
+ CREDIT,20091223120000[0:GMT],"Some Company vendorpymt PPD ID: 59728JSL20",3520.00
111
+ CREDIT,20091223120000[0:GMT],"Blarg BLARG REVENUE PPD ID: 00jah78563",1558.52
112
+ DEBIT,20091221120000[0:GMT],"WEBSITE-BALANCE-17DEC09 12 12/17WEBSITE-BAL",-12.23
113
+ DEBIT,20091214120000[0:GMT],"WEBSITE-BALANCE-10DEC09 12 12/10WEBSITE-BAL",-20.96
114
+ CREDIT,20091211120000[0:GMT],"PAYPAL TRANSFER PPD ID: PAYPALSDSL",-116.22
115
+ CREDIT,20091210120000[0:GMT],"Some Company vendorpymt PPD ID: 5KL3832735",2105.00
116
+ CSV
117
+
118
+ SOME_OTHER_CSV = (<<-CSV).strip
119
+ DEBIT,2011/12/24,"HOST 037196321563 MO 12/22SLICEHOST",($85.00)
120
+ CHECK,2010/12/24,"CHECK 2656",($20.00)
121
+ DEBIT,2009/12/24,"GITHUB 041287430274 CA 12/22GITHUB 04",($7.00)
122
+ CREDIT,2008/12/24,"Some Company vendorpymt PPD ID: 59728JSL20",$3520.00
123
+ CREDIT,2007/12/24,"Blarg BLARG REVENUE PPD ID: 00jah78563",$1558.52
124
+ DEBIT,2006/12/24,"WEBSITE-BALANCE-17DEC09 12 12/17WEBSITE-BAL",$.23
125
+ DEBIT,2005/12/24,"WEBSITE-BALANCE-10DEC09 12 12/10WEBSITE-BAL",($0.96)
126
+ CREDIT,2004/12/24,"PAYPAL TRANSFER PPD ID: PAYPALSDSL",($116.22)
127
+ CREDIT,2003/12/24,"Some Company vendorpymt PPD ID: 5KL3832735",$2105.00
128
+ CSV
129
+
130
+ TWO_MONEY_COLUMNS_BANK = (<<-CSV).strip
131
+ 4/1/2008,Check - 0000000122,122,-$76.00,"","$1,750.06"
132
+ 3/28/2008,BLARG R SH 456930,"","",+$327.49,"$1,826.06"
133
+ 3/27/2008,Check - 0000000112,112,-$800.00,"","$1,498.57"
134
+ 3/26/2008,Check - 0000000251,251,-$88.55,"","$1,298.57"
135
+ 3/26/2008,Check - 0000000251,251,"","+$88.55","$1,298.57"
136
+ CSV
137
+
138
+ end
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.dirname(__FILE__) + '/../spec_helper'
4
+ require 'rubygems'
5
+ require 'reckon'
6
+ require 'pp'
7
+
8
+ describe Reckon::LedgerParser do
9
+ before do
10
+ @ledger = Reckon::LedgerParser.new(EXAMPLE_LEDGER)
11
+ end
12
+
13
+ describe "parse" do
14
+ it "should ignore non-standard entries" do
15
+ @ledger.entries.length.should == 5
16
+ end
17
+
18
+ it "should parse entries correctly" do
19
+ @ledger.entries.first[:desc].should == "* Checking balance"
20
+ @ledger.entries.first[:date].should == "2004/05/01"
21
+ @ledger.entries.first[:accounts].first[:name].should == "Assets:Bank:Checking"
22
+ @ledger.entries.first[:accounts].first[:amount].should == 1000
23
+ @ledger.entries.first[:accounts].last[:name].should == "Equity:Opening Balances"
24
+ @ledger.entries.first[:accounts].last[:amount].should == -1000
25
+
26
+ @ledger.entries.last[:desc].should == "(100) Credit card company"
27
+ @ledger.entries.last[:date].should == "2004/05/27"
28
+ @ledger.entries.last[:accounts].first[:name].should == "Liabilities:MasterCard"
29
+ @ledger.entries.last[:accounts].first[:amount].should == 20.24
30
+ @ledger.entries.last[:accounts].last[:name].should == "Assets:Bank:Checking"
31
+ @ledger.entries.last[:accounts].last[:amount].should == -20.24
32
+ end
33
+ end
34
+
35
+ describe "balance" do
36
+ it "it should balance out missing account values" do
37
+ @ledger.balance([
38
+ { :name => "Account1", :amount => 1000 },
39
+ { :name => "Account2", :amount => nil }
40
+ ]).should == [ { :name => "Account1", :amount => 1000 }, { :name => "Account2", :amount => -1000 } ]
41
+ end
42
+
43
+ it "it should balance out missing account values" do
44
+ @ledger.balance([
45
+ { :name => "Account1", :amount => 1000 },
46
+ { :name => "Account2", :amount => 100 },
47
+ { :name => "Account3", :amount => -200 },
48
+ { :name => "Account4", :amount => nil }
49
+ ]).should == [
50
+ { :name => "Account1", :amount => 1000 },
51
+ { :name => "Account2", :amount => 100 },
52
+ { :name => "Account3", :amount => -200 },
53
+ { :name => "Account4", :amount => -900 }
54
+ ]
55
+ end
56
+
57
+ it "it should work on normal values too" do
58
+ @ledger.balance([
59
+ { :name => "Account1", :amount => 1000 },
60
+ { :name => "Account2", :amount => -1000 }
61
+ ]).should == [ { :name => "Account1", :amount => 1000 }, { :name => "Account2", :amount => -1000 } ]
62
+ end
63
+ end
64
+
65
+ # Data
66
+
67
+ EXAMPLE_LEDGER = (<<-LEDGER).strip
68
+ = /^Expenses:Books/
69
+ (Liabilities:Taxes) -0.10
70
+
71
+ ~ Monthly
72
+ Assets:Bank:Checking $500.00
73
+ Income:Salary
74
+
75
+ 2004/05/01 * Checking balance
76
+ Assets:Bank:Checking $1,000.00
77
+ Equity:Opening Balances
78
+
79
+ 2004/05/01 * Investment balance
80
+ Assets:Brokerage 50 AAPL @ $30.00
81
+ Equity:Opening Balances
82
+
83
+ ; blah
84
+ !account blah
85
+
86
+ !end
87
+
88
+ D $1,000
89
+
90
+ 2004/05/14 * Pay day
91
+ Assets:Bank:Checking $500.00
92
+ Income:Salary
93
+
94
+ 2004/05/27 Book Store
95
+ Expenses:Books $20.00
96
+ Liabilities:MasterCard
97
+ 2004/05/27 (100) Credit card company
98
+ Liabilities:MasterCard $20.24
99
+ Assets:Bank:Checking
100
+ LEDGER
101
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,9 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ require 'reckon'
4
+ require 'spec'
5
+ require 'spec/autorun'
6
+
7
+ Spec::Runner.configure do |config|
8
+
9
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reckon
3
+ version: !ruby/object:Gem::Version
4
+ hash: 23
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 2
9
+ - 0
10
+ version: 0.2.0
11
+ platform: ruby
12
+ authors:
13
+ - Andrew Cantino
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-11-06 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: rspec
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 13
30
+ segments:
31
+ - 1
32
+ - 2
33
+ - 9
34
+ version: 1.2.9
35
+ type: :development
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: fastercsv
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ hash: 1
46
+ segments:
47
+ - 1
48
+ - 5
49
+ - 1
50
+ version: 1.5.1
51
+ type: :runtime
52
+ version_requirements: *id002
53
+ - !ruby/object:Gem::Dependency
54
+ name: highline
55
+ prerelease: false
56
+ requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ hash: 7
62
+ segments:
63
+ - 1
64
+ - 5
65
+ - 2
66
+ version: 1.5.2
67
+ type: :runtime
68
+ version_requirements: *id003
69
+ - !ruby/object:Gem::Dependency
70
+ name: terminal-table
71
+ prerelease: false
72
+ requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ hash: 3
78
+ segments:
79
+ - 1
80
+ - 4
81
+ - 2
82
+ version: 1.4.2
83
+ type: :runtime
84
+ version_requirements: *id004
85
+ description: Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.
86
+ email: andrew@iterationlabs.com
87
+ executables:
88
+ - reckon
89
+ - reckon
90
+ extensions: []
91
+
92
+ extra_rdoc_files:
93
+ - LICENSE
94
+ - README.rdoc
95
+ files:
96
+ - .document
97
+ - .gitignore
98
+ - LICENSE
99
+ - README.rdoc
100
+ - Rakefile
101
+ - VERSION
102
+ - bin/reckon
103
+ - lib/reckon.rb
104
+ - lib/reckon/app.rb
105
+ - lib/reckon/ledger_parser.rb
106
+ - reckon.gemspec
107
+ - spec/reckon/app_spec.rb
108
+ - spec/reckon/ledger_parser_spec.rb
109
+ - spec/spec.opts
110
+ - spec/spec_helper.rb
111
+ has_rdoc: true
112
+ homepage: http://github.com/iterationlabs/reckon
113
+ licenses: []
114
+
115
+ post_install_message:
116
+ rdoc_options:
117
+ - --charset=UTF-8
118
+ require_paths:
119
+ - lib
120
+ required_ruby_version: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ hash: 3
126
+ segments:
127
+ - 0
128
+ version: "0"
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ none: false
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ hash: 3
135
+ segments:
136
+ - 0
137
+ version: "0"
138
+ requirements: []
139
+
140
+ rubyforge_project:
141
+ rubygems_version: 1.3.7
142
+ signing_key:
143
+ specification_version: 3
144
+ summary: Utility for interactively converting and labeling CSV files for the Ledger accounting tool.
145
+ test_files:
146
+ - spec/reckon/app_spec.rb
147
+ - spec/reckon/ledger_parser_spec.rb
148
+ - spec/spec_helper.rb