reckon 0.8.1 → 0.9.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.
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)