reckon 0.2.0

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/.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