reckon 0.8.1 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +1 -1
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +20 -0
  5. data/CHANGELOG.md +22 -0
  6. data/Gemfile.lock +21 -21
  7. data/README.md +2 -0
  8. data/Rakefile +2 -2
  9. data/bin/build-new-version.sh +3 -2
  10. data/bin/reckon +1 -1
  11. data/lib/reckon/app.rb +27 -24
  12. data/lib/reckon/beancount_parser.rb +150 -0
  13. data/lib/reckon/cosine_similarity.rb +0 -1
  14. data/lib/reckon/csv_parser.rb +89 -44
  15. data/lib/reckon/date_column.rb +18 -7
  16. data/lib/reckon/ledger_parser.rb +23 -15
  17. data/lib/reckon/money.rb +18 -16
  18. data/lib/reckon/options.rb +47 -18
  19. data/lib/reckon/version.rb +1 -1
  20. data/lib/reckon.rb +1 -0
  21. data/spec/cosine_training_and_test.rb +1 -1
  22. data/spec/data_fixtures/multi-line-field.csv +5 -0
  23. data/spec/integration/ask_for_account/cli_input.txt +1 -0
  24. data/spec/integration/invalid_header_example/output.ledger +6 -7
  25. data/spec/integration/invalid_header_example/test_args +1 -1
  26. data/spec/integration/tab_delimited_file/input.csv +2 -0
  27. data/spec/integration/tab_delimited_file/output.ledger +8 -0
  28. data/spec/integration/tab_delimited_file/test_args +1 -0
  29. data/spec/integration/test.sh +3 -5
  30. data/spec/integration/two_money_columns_manual/input.csv +5 -0
  31. data/spec/integration/two_money_columns_manual/output.ledger +16 -0
  32. data/spec/integration/two_money_columns_manual/test_args +1 -0
  33. data/spec/reckon/csv_parser_spec.rb +85 -26
  34. data/spec/reckon/date_column_spec.rb +6 -0
  35. data/spec/reckon/ledger_parser_spec.rb +25 -23
  36. data/spec/reckon/options_spec.rb +2 -2
  37. data/spec/spec_helper.rb +2 -0
  38. metadata +17 -141
  39. data/spec/integration/ask_for_account/cli_input.exp +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08cdfad096b04db11f671775c0119fcecefa7db46134f1c43677e949628498db'
4
- data.tar.gz: 5ed2722b2843a5147faabdd82b0027fe590ff9dd66000100e756fad6de13a116
3
+ metadata.gz: 03c20b48d4333969c8304a5bb9a3c01fc6053050ab9146329ce14ae6a9886b38
4
+ data.tar.gz: 27a2ce4e8db5c7818cc4cefb19f180a7c727190f0a990403f565fad503e749a9
5
5
  SHA512:
6
- metadata.gz: 7ebe28c482f091424d405efa1dc4b94cdc76022b114ce6f0db22a96e3ec6104a6e1efecec833af981f9c97f4c705cbbbd7f791403b5b669452abccd2caf295c8
7
- data.tar.gz: 7af0ef7c71bcca9b189d23939ebeb93eedbe0875373818419d878461c33035a5e92359204c939ac3d8933935ad29b8a9cec319ddb84ea13626e22f51897f2fea
6
+ metadata.gz: 2f569b3d5cf4038714065a6d184d6c07f57d10598e5efc610eeb9919e8b18c65aff5e5329ab89a9ed30f72cabce9d11f5645af4d0df3bda6d05ad9afd988f7e7
7
+ data.tar.gz: 1783a63ba138c2b87a0756d6b9bcfbce068daf977e582a4c920a37ff50358328f8514f308dbbf932ef5cc4111e9e52dadfaed5876b9d30f4759d4a1eb31299fa
@@ -31,7 +31,7 @@ jobs:
31
31
  - name: Update package
32
32
  run: sudo apt-get update
33
33
  - name: Install packages
34
- run: sudo apt-get -y install ledger hledger expect
34
+ run: sudo apt-get -y install ledger hledger
35
35
  - name: Set up Ruby
36
36
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
37
37
  # change this to (see https://github.com/ruby/setup-ruby#versioning):
data/.gitignore CHANGED
@@ -29,6 +29,11 @@ private_tests
29
29
  ## Bundler
30
30
  vendor
31
31
  .bundle
32
+ # binstubs
33
+ exec/
34
+
35
+ # Don't commit gem files
36
+ *.gem
32
37
 
33
38
  test.log
34
39
  test_log.txt
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ Layout/LineLength:
2
+ Max: 88
3
+
4
+ Style/StringLiterals:
5
+ Enabled: false
6
+
7
+ Style/RedundantReturn:
8
+ Enabled: false
9
+
10
+ Metrics/ClassLength:
11
+ Enabled: False
12
+
13
+ Metrics/MethodLength:
14
+ Enabled: False
15
+
16
+ Metrics/AbcSize:
17
+ Enabled: False
18
+
19
+ Style/NumericPredicate:
20
+ Enabled: False
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.1](https://github.com/cantino/reckon/tree/v0.9.1) (2023-03-19)
4
+
5
+ [Full Changelog](https://github.com/cantino/reckon/compare/v0.9.0...v0.9.1)
6
+
7
+ **Closed issues:**
8
+
9
+ - More than one column support [\#120](https://github.com/cantino/reckon/issues/120)
10
+ - Beancount support [\#119](https://github.com/cantino/reckon/issues/119)
11
+ - Problem with importing CSV [\#60](https://github.com/cantino/reckon/issues/60)
12
+
13
+ ## [v0.9.0](https://github.com/cantino/reckon/tree/v0.9.0) (2023-02-23)
14
+
15
+ [Full Changelog](https://github.com/cantino/reckon/compare/v0.9.0-beta...v0.9.0)
16
+
17
+ **Merged pull requests:**
18
+
19
+ - Add support for multiple money columns [\#118](https://github.com/cantino/reckon/pull/118) ([oskarth](https://github.com/oskarth))
20
+
21
+ ## [v0.9.0-beta](https://github.com/cantino/reckon/tree/v0.9.0-beta) (2023-02-21)
22
+
23
+ [Full Changelog](https://github.com/cantino/reckon/compare/v0.8.1...v0.9.0-beta)
24
+
3
25
  ## [v0.8.1](https://github.com/cantino/reckon/tree/v0.8.1) (2022-07-02)
4
26
 
5
27
  [Full Changelog](https://github.com/cantino/reckon/compare/v0.8.0...v0.8.1)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- reckon (0.8.1)
4
+ reckon (0.9.1)
5
5
  chronic (>= 0.3.0)
6
6
  highline (>= 1.5.2)
7
7
  matrix (>= 0.4.2)
@@ -11,30 +11,30 @@ GEM
11
11
  remote: http://rubygems.org/
12
12
  specs:
13
13
  chronic (0.10.2)
14
- coderay (1.1.2)
15
- diff-lcs (1.3)
16
- highline (2.0.3)
14
+ coderay (1.1.3)
15
+ diff-lcs (1.5.0)
16
+ highline (2.1.0)
17
17
  matrix (0.4.2)
18
- method_source (0.9.2)
19
- pry (0.12.2)
20
- coderay (~> 1.1.0)
21
- method_source (~> 0.9.0)
22
- rake (12.3.3)
18
+ method_source (1.0.0)
19
+ pry (0.14.2)
20
+ coderay (~> 1.1)
21
+ method_source (~> 1.0)
22
+ rake (13.0.6)
23
23
  rantly (1.2.0)
24
24
  rchardet (1.8.0)
25
- rspec (3.9.0)
26
- rspec-core (~> 3.9.0)
27
- rspec-expectations (~> 3.9.0)
28
- rspec-mocks (~> 3.9.0)
29
- rspec-core (3.9.1)
30
- rspec-support (~> 3.9.1)
31
- rspec-expectations (3.9.0)
25
+ rspec (3.12.0)
26
+ rspec-core (~> 3.12.0)
27
+ rspec-expectations (~> 3.12.0)
28
+ rspec-mocks (~> 3.12.0)
29
+ rspec-core (3.12.1)
30
+ rspec-support (~> 3.12.0)
31
+ rspec-expectations (3.12.2)
32
32
  diff-lcs (>= 1.2.0, < 2.0)
33
- rspec-support (~> 3.9.0)
34
- rspec-mocks (3.9.1)
33
+ rspec-support (~> 3.12.0)
34
+ rspec-mocks (3.12.3)
35
35
  diff-lcs (>= 1.2.0, < 2.0)
36
- rspec-support (~> 3.9.0)
37
- rspec-support (3.9.2)
36
+ rspec-support (~> 3.12.0)
37
+ rspec-support (3.12.0)
38
38
 
39
39
  PLATFORMS
40
40
  ruby
@@ -47,4 +47,4 @@ DEPENDENCIES
47
47
  rspec (>= 1.2.9)
48
48
 
49
49
  BUNDLED WITH
50
- 1.17.3
50
+ 2.3.5
data/README.md CHANGED
@@ -45,6 +45,8 @@ Learn more:
45
45
  Columns to ignore, starts from 1
46
46
  --money-column 2
47
47
  Column number of the money column, starts from 1
48
+ --money-columns 2,3
49
+ Column number of the money columns, starts from 1 (1 or 2 columns)
48
50
  --raw-money
49
51
  Don't format money column (for stocks)
50
52
  --date-column 3
data/Rakefile CHANGED
@@ -13,10 +13,10 @@ task :test_all do
13
13
  puts "Running unit tests"
14
14
  Rake::Task["spec"].invoke
15
15
  puts "Running integration tests"
16
- Rake::Task["integration_tests"].invoke
16
+ Rake::Task["test_integration"].invoke
17
17
  end
18
18
 
19
- task :integration_tests do
19
+ task :test_integration do
20
20
  cmd = 'prove -v ./spec/integration/test.sh'
21
21
  raise 'Integration tests failed' unless system(cmd)
22
22
  end
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
 
3
- set -e
3
+ set -xe
4
4
 
5
5
  VERSION=$1
6
6
 
@@ -8,7 +8,7 @@ echo "Install github_changelog_generator"
8
8
  gem install --user github_changelog_generator
9
9
 
10
10
  echo "Update 'lib/reckon/version.rb'"
11
- echo -e "module Reckon\n VERSION=\"$VERSION\"\nend" > lib/reckon/version.rb
11
+ echo -e "module Reckon\n VERSION = \"$VERSION\"\nend" > lib/reckon/version.rb
12
12
  echo "Run `bundle install` to build updated Gemfile.lock"
13
13
  bundle install
14
14
  echo "Run changelog generator (requires $TOKEN to be your github token)"
@@ -24,3 +24,4 @@ echo "Push changes and tags"
24
24
  echo "git push && git push --tags"
25
25
  echo "Push new gem"
26
26
  echo "gem push reckon-$VERSION.gem"
27
+ gh release create v$VERSION reckon-$VERSION.gem --draft --generate-notes
data/bin/reckon CHANGED
@@ -4,7 +4,7 @@ require 'rubygems'
4
4
  require 'reckon'
5
5
 
6
6
  begin
7
- options = Reckon::Options.parse
7
+ options = Reckon::Options.parse_command_line_options
8
8
  rescue RuntimeError => e
9
9
  puts("ERROR: #{e}")
10
10
  exit(1)
data/lib/reckon/app.rb CHANGED
@@ -1,12 +1,12 @@
1
- # coding: utf-8
1
+ # frozen_string_literal: true
2
2
 
3
- require 'pp'
4
3
  require 'yaml'
4
+ require 'stringio'
5
5
 
6
6
  module Reckon
7
+ # The main app
7
8
  class App
8
9
  attr_accessor :options, :seen, :csv_parser, :regexps, :matcher
9
- @@cli = HighLine.new
10
10
 
11
11
  def initialize(opts = {})
12
12
  self.options = opts
@@ -14,9 +14,10 @@ module Reckon
14
14
 
15
15
  self.regexps = {}
16
16
  self.seen = Set.new
17
- self.options[:currency] ||= '$'
18
- @csv_parser = CSVParser.new( options )
17
+ @cli = HighLine.new
18
+ @csv_parser = CSVParser.new(options)
19
19
  @matcher = CosineSimilarity.new(options)
20
+ @parser = options[:format] =~ /beancount/i ? BeancountParser.new : LedgerParser.new
20
21
  learn!
21
22
  end
22
23
 
@@ -26,9 +27,13 @@ module Reckon
26
27
  fh.puts str
27
28
  end
28
29
 
30
+ # Learn from previous transactions. Used to recommend accounts for a transaction.
29
31
  def learn!
30
32
  learn_from_account_tokens(options[:account_tokens_file])
31
33
  learn_from_ledger_file(options[:existing_ledger_file])
34
+ # TODO: make this work
35
+ # this doesn't work because output_file is an IO object
36
+ # learn_from_ledger_file(options[:output_file]) if File.exist?(options[:output_file])
32
37
  end
33
38
 
34
39
  def learn_from_account_tokens(filename)
@@ -52,12 +57,13 @@ module Reckon
52
57
 
53
58
  raise "#{ledger_file} doesn't exist!" unless File.exist?(ledger_file)
54
59
 
55
- learn_from_ledger(File.read(ledger_file))
60
+ learn_from_ledger(File.new(ledger_file))
56
61
  end
57
62
 
63
+ # Takes an IO-like object
58
64
  def learn_from_ledger(ledger)
59
65
  LOGGER.info "learning from #{ledger}"
60
- LedgerParser.new(ledger).entries.each do |entry|
66
+ @parser.parse(ledger).each do |entry|
61
67
  entry[:accounts].each do |account|
62
68
  str = [entry[:desc], account[:amount]].join(" ")
63
69
  if account[:name] != options[:bank_account]
@@ -84,7 +90,7 @@ module Reckon
84
90
  merged_acct = [account, k].compact.join(':')
85
91
  extract_account_tokens(v, merged_acct)
86
92
  end
87
- at.inject({}) { |memo, e| memo.merge!(e)}
93
+ at.inject({}) { |memo, e| memo.merge!(e) }
88
94
  end
89
95
  end
90
96
 
@@ -92,6 +98,7 @@ module Reckon
92
98
  # https://github.com/tenderlove/psych/blob/master/lib/psych/visitors/to_ruby.rb
93
99
  match = regex_str.match(/^\/(.*)\/([ix]*)$/m)
94
100
  fail "failed to parse regexp #{regex_str}" unless match
101
+
95
102
  options = 0
96
103
  (match[2] || '').split('').each do |option|
97
104
  case option
@@ -120,13 +127,16 @@ module Reckon
120
127
 
121
128
  if row[:money] > 0
122
129
  # out_of_account
123
- answer = ask_account_question("Which account provided this income? (#{cmd_options})", row)
130
+ answer = ask_account_question(
131
+ "Which account provided this income? (#{cmd_options})", row
132
+ )
124
133
  line1 = [options[:bank_account], row[:pretty_money]]
125
134
  line2 = [answer, ""]
126
135
  else
127
136
  # into_account
128
- answer = ask_account_question("To which account did this money go? (#{cmd_options})", row)
129
- # line1 = [answer, row[:pretty_money_negated]]
137
+ answer = ask_account_question(
138
+ "To which account did this money go? (#{cmd_options})", row
139
+ )
130
140
  line1 = [answer, ""]
131
141
  line2 = [options[:bank_account], row[:pretty_money]]
132
142
  end
@@ -137,9 +147,9 @@ module Reckon
137
147
  next
138
148
  end
139
149
 
140
- ledger = ledger_format(row, line1, line2)
150
+ ledger = @parser.format_row(row, line1, line2)
141
151
  LOGGER.info "ledger line: #{ledger}"
142
- learn_from_ledger(ledger) unless options[:account_tokens_file]
152
+ learn_from_ledger(StringIO.new(ledger)) unless options[:account_tokens_file]
143
153
  output(ledger)
144
154
  end
145
155
  end
@@ -203,7 +213,7 @@ module Reckon
203
213
  return possible_answers[0] || default
204
214
  end
205
215
 
206
- answer = @@cli.ask(msg) do |q|
216
+ answer = @cli.ask(msg) do |q|
207
217
  q.completion = possible_answers
208
218
  q.readline = true
209
219
  q.default = possible_answers.first
@@ -221,7 +231,7 @@ module Reckon
221
231
  end
222
232
 
223
233
  def add_description(row)
224
- desc_answer = @@cli.ask("Enter a new description for this transaction (empty line aborts)\n") do |q|
234
+ desc_answer = @cli.ask("Enter a new description for this transaction (empty line aborts)\n") do |q|
225
235
  q.overwrite = true
226
236
  q.readline = true
227
237
  q.default = row[:description]
@@ -231,7 +241,7 @@ module Reckon
231
241
  end
232
242
 
233
243
  def add_note(row)
234
- desc_answer = @@cli.ask("Enter a new note for this transaction (empty line aborts)\n") do |q|
244
+ desc_answer = @cli.ask("Enter a new note for this transaction (empty line aborts)\n") do |q|
235
245
  q.overwrite = true
236
246
  q.readline = true
237
247
  q.default = row[:note]
@@ -246,7 +256,7 @@ module Reckon
246
256
  [account, match[0]]
247
257
  end
248
258
  }.compact
249
- matches.sort_by! { |_account, matched_text| matched_text.length }.map(&:first)
259
+ matches.sort_by { |_account, matched_text| matched_text.length }.map(&:first)
250
260
  end
251
261
 
252
262
  def suggest(row)
@@ -254,13 +264,6 @@ module Reckon
254
264
  @matcher.find_similar(row[:description]).map { |n| n[:account] }
255
265
  end
256
266
 
257
- def ledger_format(row, line1, line2)
258
- out = "#{row[:pretty_date]}\t#{row[:description]}#{row[:note] ? "\t; " + row[:note]: ""}\n"
259
- out += "\t#{line1.first}\t\t\t#{line1.last}\n"
260
- out += "\t#{line2.first}\t\t\t#{line2.last}\n\n"
261
- out
262
- end
263
-
264
267
  def output(ledger_line)
265
268
  options[:output_file].puts ledger_line
266
269
  options[:output_file].flush
@@ -0,0 +1,150 @@
1
+ require 'rubygems'
2
+ require 'date'
3
+
4
+ module Reckon
5
+ class BeancountParser
6
+
7
+ attr_accessor :entries
8
+
9
+ def initialize(options = {})
10
+ @options = options
11
+ @date_format = options[:ledger_date_format] || options[:date_format] || '%Y-%m-%d'
12
+ end
13
+
14
+ # 2015-01-01 * "Opening Balance for checking account"
15
+ # Assets:US:BofA:Checking 3490.52 USD
16
+ # Equity:Opening-Balances -3490.52 USD
17
+
18
+ # input is an object that response to #each_line,
19
+ # (i.e. a StringIO or an IO object)
20
+ def parse(input)
21
+ entries = []
22
+ comment_chars = ';#%*|'
23
+ new_entry = {}
24
+
25
+ input.each_line do |entry|
26
+
27
+ next if entry =~ /^\s*[#{comment_chars}]/
28
+
29
+ m = entry.match(%r{
30
+ ^
31
+ (\d+[\d/-]+) # date
32
+ \s+
33
+ ([*!])? # type
34
+ \s*
35
+ ("[^"]*")? # description (optional)
36
+ \s*
37
+ ("[^"]*")? # notes (optional)
38
+ # tags (not implemented)
39
+ }x)
40
+
41
+ # (date, type, code, description), type and code are optional
42
+ if (m)
43
+ add_entry(entries, new_entry)
44
+ new_entry = {
45
+ date: try_parse_date(m[1]),
46
+ type: m[2] || "",
47
+ desc: trim_quote(m[3]),
48
+ notes: trim_quote(m[4]),
49
+ accounts: []
50
+ }
51
+ elsif entry =~ /^\s*$/ && new_entry[:date]
52
+ add_entry(entries, new_entry)
53
+ new_entry = {}
54
+ elsif new_entry[:date] && entry =~ /^\s+/
55
+ LOGGER.info("Adding new account #{entry}")
56
+ new_entry[:accounts] << parse_account_line(entry)
57
+ else
58
+ LOGGER.info("Unknown entry type: #{entry}")
59
+ add_entry(entries, new_entry)
60
+ new_entry = {}
61
+ end
62
+
63
+ end
64
+ entries
65
+ end
66
+
67
+ def format_row(row, line1, line2)
68
+ out = %Q{#{row[:pretty_date]} * "#{row[:description]}" "#{row[:note]}\n}
69
+ out += "\t#{line1.first}\t\t\t#{line1.last}\n"
70
+ out += "\t#{line2.first}\t\t\t#{line2.last}\n\n"
71
+ out
72
+ end
73
+
74
+ private
75
+
76
+ # remove leading and trailing quote character (")
77
+ def trim_quote(str)
78
+ return str if !str
79
+ str.gsub(/^"([^"]*)"$/, '\1')
80
+ end
81
+
82
+ def add_entry(entries, entry)
83
+ return unless entry[:date] && entry[:accounts].length > 1
84
+
85
+ entry[:accounts] = balance(entry[:accounts])
86
+ entries << entry
87
+ end
88
+
89
+ def try_parse_date(date_str)
90
+ date = Date.parse(date_str)
91
+ return nil if date.year > 9999 || date.year < 1000
92
+
93
+ date
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+
98
+ def parse_account_line(entry)
99
+ # TODO handle buying stocks
100
+ # Assets:US:ETrade:VHT 19 VHT {132.32 USD, 2017-08-27}
101
+ (account_name, rest) = entry.strip.split(/\s{2,}|\t+/, 2)
102
+
103
+ if rest.nil? || rest.empty?
104
+ return {
105
+ name: account_name,
106
+ amount: clean_money("")
107
+ }
108
+ end
109
+
110
+ value = if rest =~ /{/
111
+ (qty, dollar_value, date) = rest.split(/[{,]/)
112
+ (qty.to_f * dollar_value.to_f).to_s
113
+ else
114
+ rest
115
+ end
116
+
117
+ return {
118
+ name: account_name,
119
+ amount: clean_money(value || "")
120
+ }
121
+ end
122
+
123
+ def balance(accounts)
124
+ return accounts unless accounts.any? { |i| i[:amount].nil? }
125
+
126
+ sum = accounts.reduce(0) { |m, n| m + (n[:amount] || 0) }
127
+ count = 0
128
+ accounts.each do |account|
129
+ next unless account[:amount].nil?
130
+
131
+ count += 1
132
+ account[:amount] = -sum
133
+ end
134
+ if count > 1
135
+ puts "Warning: unparsable entry due to more than one missing money value."
136
+ p accounts
137
+ puts
138
+ end
139
+
140
+ accounts
141
+ end
142
+
143
+ def clean_money(money)
144
+ return nil if money.nil? || money.empty?
145
+
146
+ money.gsub(/[^0-9.-]/, '').to_f
147
+ end
148
+ end
149
+ end
150
+
@@ -17,7 +17,6 @@ module Reckon
17
17
 
18
18
  def initialize(options)
19
19
  @docs = DocumentInfo.new({}, {})
20
- @options = options
21
20
  end
22
21
 
23
22
  def add_document(account, doc)