reckon 0.4.4 → 0.5.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.
- checksums.yaml +5 -5
- data/.ruby-version +1 -1
- data/.travis.yml +10 -2
- data/CHANGELOG.md +197 -0
- data/Gemfile +0 -1
- data/Gemfile.lock +33 -15
- data/README.md +2 -5
- data/lib/reckon.rb +10 -8
- data/lib/reckon/app.rb +92 -116
- data/lib/reckon/cosine_similarity.rb +119 -0
- data/lib/reckon/csv_parser.rb +57 -27
- data/lib/reckon/ledger_parser.rb +194 -30
- data/lib/reckon/money.rb +3 -4
- data/reckon.gemspec +6 -5
- data/spec/data_fixtures/73-sample.csv +2 -0
- data/spec/data_fixtures/73-tokens.yml +8 -0
- data/spec/data_fixtures/73-transactions.ledger +7 -0
- data/spec/data_fixtures/austrian_example.csv +13 -0
- data/spec/data_fixtures/bom_utf8_file.csv +1 -0
- data/spec/data_fixtures/broker_canada_example.csv +12 -0
- data/spec/data_fixtures/chase.csv +9 -0
- data/spec/data_fixtures/danish_kroner_nordea_example.csv +6 -0
- data/spec/data_fixtures/english_date_example.csv +3 -0
- data/spec/data_fixtures/french_example.csv +9 -0
- data/spec/data_fixtures/german_date_example.csv +3 -0
- data/spec/data_fixtures/harder_date_example.csv +5 -0
- data/spec/data_fixtures/ing.csv +3 -0
- data/spec/data_fixtures/intuit_mint_example.csv +7 -0
- data/spec/data_fixtures/invalid_header_example.csv +6 -0
- data/spec/data_fixtures/inversed_credit_card.csv +16 -0
- data/spec/data_fixtures/nationwide.csv +4 -0
- data/spec/data_fixtures/simple.csv +2 -0
- data/spec/data_fixtures/some_other.csv +9 -0
- data/spec/data_fixtures/spanish_date_example.csv +3 -0
- data/spec/data_fixtures/suntrust.csv +7 -0
- data/spec/data_fixtures/two_money_columns.csv +5 -0
- data/spec/data_fixtures/yyyymmdd_date_example.csv +1 -0
- data/spec/reckon/app_spec.rb +66 -34
- data/spec/reckon/csv_parser_spec.rb +79 -201
- data/spec/reckon/ledger_parser_spec.rb +62 -9
- data/spec/spec_helper.rb +3 -0
- metadata +62 -19
- data/CHANGES.md +0 -9
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'matrix'
|
2
|
+
|
3
|
+
# Implementation of consine similarity using TF-IDF for vectorization.
|
4
|
+
# Used to suggest which account a transaction should be assigned to
|
5
|
+
class CosineSimilarity
|
6
|
+
def initialize(options)
|
7
|
+
@options = options
|
8
|
+
@tokens = {}
|
9
|
+
@accounts = Hash.new(0)
|
10
|
+
end
|
11
|
+
|
12
|
+
def add_document(account, doc)
|
13
|
+
tokenize(doc).each do |n|
|
14
|
+
(token, count) = n
|
15
|
+
|
16
|
+
@tokens[token] ||= {}
|
17
|
+
@tokens[token][account] ||= 0
|
18
|
+
@tokens[token][account] += count
|
19
|
+
@accounts[account] += count
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# find most similar documents to query
|
24
|
+
def find_similar(query)
|
25
|
+
(query_scores, corpus_scores) = td_idf_scores_for(query)
|
26
|
+
|
27
|
+
query_vector = Vector.elements(query_scores, false)
|
28
|
+
|
29
|
+
# For each doc, calculate the similarity to the query
|
30
|
+
suggestions = corpus_scores.map do |account, scores|
|
31
|
+
acct_vector = Vector.elements(scores, false)
|
32
|
+
|
33
|
+
acct_query_dp = acct_vector.inner_product(query_vector)
|
34
|
+
# similarity is a float between 1 and -1, where 1 is exactly the same and -1 is
|
35
|
+
# exactly opposite
|
36
|
+
# see https://en.wikipedia.org/wiki/Cosine_similarity
|
37
|
+
# cos(theta) = (A . B) / (||A|| ||B||)
|
38
|
+
# where A . B is the "dot product" and ||A|| is the magnitude of A
|
39
|
+
# ruby has the 'matrix' library we can use to do these calculations.
|
40
|
+
{
|
41
|
+
similarity: acct_query_dp / (acct_vector.magnitude * query_vector.magnitude),
|
42
|
+
account: account,
|
43
|
+
}
|
44
|
+
end.select { |n| n[:similarity] > 0 }.sort_by { |n| -n[:similarity] }
|
45
|
+
|
46
|
+
LOGGER.info "most similar accounts: #{suggestions}"
|
47
|
+
|
48
|
+
return suggestions
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def td_idf_scores_for(query)
|
54
|
+
query_tokens = tokenize(query)
|
55
|
+
corpus = Set.new
|
56
|
+
corpus_scores = {}
|
57
|
+
query_scores = []
|
58
|
+
num_docs = @accounts.length
|
59
|
+
|
60
|
+
query_tokens.each do |n|
|
61
|
+
(token, _count) = n
|
62
|
+
next unless @tokens[token]
|
63
|
+
corpus = corpus.union(Set.new(@tokens[token].keys))
|
64
|
+
end
|
65
|
+
|
66
|
+
query_tokens.each do |n|
|
67
|
+
(token, count) = n
|
68
|
+
|
69
|
+
# if no other docs have token, ignore it
|
70
|
+
next unless @tokens[token]
|
71
|
+
|
72
|
+
## First, calculate scores for our query as we're building scores for the corpus
|
73
|
+
query_scores << calc_tf_idf(
|
74
|
+
count,
|
75
|
+
query_tokens.length,
|
76
|
+
@tokens[token].length,
|
77
|
+
num_docs
|
78
|
+
)
|
79
|
+
|
80
|
+
## Next, calculate for the corpus, where our "account" is a document
|
81
|
+
corpus.each do |account|
|
82
|
+
corpus_scores[account] ||= []
|
83
|
+
|
84
|
+
corpus_scores[account] << calc_tf_idf(
|
85
|
+
(@tokens[token][account] || 0),
|
86
|
+
@accounts[account].to_f,
|
87
|
+
@tokens[token].length.to_f,
|
88
|
+
num_docs
|
89
|
+
)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
[query_scores, corpus_scores]
|
93
|
+
end
|
94
|
+
|
95
|
+
def calc_tf_idf(token_count, num_words_in_doc, df, num_docs)
|
96
|
+
|
97
|
+
# tf(t,d) = count of t in d / number of words in d
|
98
|
+
tf = token_count / num_words_in_doc.to_f
|
99
|
+
|
100
|
+
# smooth idf weight
|
101
|
+
# see https://en.wikipedia.org/wiki/Tf%E2%80%93idf#Inverse_document_frequency_2
|
102
|
+
# df(t) = num of documents with term t in them
|
103
|
+
# idf(t) = log(N/(1 + df )) + 1
|
104
|
+
idf = Math.log(num_docs.to_f / (1 + df)) + 1
|
105
|
+
|
106
|
+
tf * idf
|
107
|
+
end
|
108
|
+
|
109
|
+
def tokenize(str)
|
110
|
+
mk_tokens(str).inject(Hash.new(0)) do |memo, n|
|
111
|
+
memo[n] += 1
|
112
|
+
memo
|
113
|
+
end.to_a
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def mk_tokens(str)
|
118
|
+
str.downcase.tr(';', ' ').tr("'", '').split(/[^a-z0-9.]+/)
|
119
|
+
end
|
data/lib/reckon/csv_parser.rb
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
#coding: utf-8
|
2
|
-
require 'pp'
|
3
2
|
|
4
3
|
module Reckon
|
5
4
|
class CSVParser
|
@@ -8,7 +7,7 @@ module Reckon
|
|
8
7
|
def initialize(options = {})
|
9
8
|
self.options = options
|
10
9
|
self.options[:currency] ||= '$'
|
11
|
-
parse
|
10
|
+
@csv_data = parse(options[:string] || File.read(options[:file]), options[:file])
|
12
11
|
filter_csv
|
13
12
|
detect_columns
|
14
13
|
end
|
@@ -44,7 +43,7 @@ module Reckon
|
|
44
43
|
end
|
45
44
|
|
46
45
|
def description_for(index)
|
47
|
-
description_column_indices.map { |i| columns[i][index] }.reject
|
46
|
+
description_column_indices.map { |i| columns[i][index] }.reject(&:empty?).join("; ").squeeze(" ").gsub(/(;\s+){2,}/, '').strip
|
48
47
|
end
|
49
48
|
|
50
49
|
def evaluate_columns(cols)
|
@@ -160,7 +159,12 @@ module Reckon
|
|
160
159
|
|
161
160
|
def detect_columns
|
162
161
|
results, found_likely_money_column = evaluate_columns(columns)
|
163
|
-
|
162
|
+
if options[:money_column]
|
163
|
+
found_likely_money_column = true
|
164
|
+
self.money_column_indices = [ options[:money_column] - 1 ]
|
165
|
+
else
|
166
|
+
self.money_column_indices = [ results.max_by { |n| n[:money_score] }[:index] ]
|
167
|
+
end
|
164
168
|
|
165
169
|
if !found_likely_money_column
|
166
170
|
found_likely_double_money_columns = false
|
@@ -192,20 +196,21 @@ module Reckon
|
|
192
196
|
end
|
193
197
|
end
|
194
198
|
|
195
|
-
results.reject! {|i| money_column_indices.include?(i[:index]) }
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
+
results.reject! { |i| money_column_indices.include?(i[:index]) }
|
200
|
+
if options[:date_column]
|
201
|
+
@date_column_index = options[:date_column] - 1
|
202
|
+
else
|
203
|
+
# sort by highest score followed by lowest index
|
204
|
+
@date_column_index = results.max_by { |n| [n[:date_score], -n[:index]] }[:index]
|
205
|
+
end
|
206
|
+
results.reject! { |i| i[:index] == date_column_index }
|
207
|
+
@date_column = DateColumn.new(columns[date_column_index], @options)
|
199
208
|
|
200
|
-
|
201
|
-
|
202
|
-
@options )
|
209
|
+
@money_column = MoneyColumn.new(columns[money_column_indices[0]], @options)
|
210
|
+
if money_column_indices.length == 1
|
203
211
|
detect_sign_column if @money_column.positive?
|
204
212
|
else
|
205
|
-
@money_column
|
206
|
-
@options )
|
207
|
-
@money_column.merge!(
|
208
|
-
MoneyColumn.new( columns[money_column_indices[1]], @options ) )
|
213
|
+
@money_column.merge! MoneyColumn.new(columns[money_column_indices[1]], @options)
|
209
214
|
end
|
210
215
|
|
211
216
|
self.description_column_indices = results.map { |i| i[:index] }
|
@@ -228,21 +233,46 @@ module Reckon
|
|
228
233
|
end
|
229
234
|
end
|
230
235
|
|
231
|
-
def parse
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
236
|
+
def parse(data, filename=nil)
|
237
|
+
# Use force_encoding to convert the string to utf-8 with as few invalid characters
|
238
|
+
# as possible.
|
239
|
+
data.force_encoding(try_encoding(data, filename))
|
240
|
+
data = data.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
241
|
+
data.sub!("\xEF\xBB\xBF", '') # strip byte order marker, if it exists
|
242
|
+
|
243
|
+
rows = []
|
244
|
+
data.each_line.with_index do |line, i|
|
245
|
+
next if i < (options[:contains_header] || 0)
|
246
|
+
rows << CSV.parse_line(line, col_sep: options[:csv_separator] || ',')
|
239
247
|
end
|
240
248
|
|
241
|
-
|
242
|
-
|
243
|
-
|
249
|
+
rows
|
250
|
+
end
|
251
|
+
|
252
|
+
def try_encoding(data, filename = nil)
|
253
|
+
encoding = try_encoding_from_file(filename)
|
254
|
+
|
255
|
+
cd = CharDet.detect(data)
|
256
|
+
encoding ||= cd['encoding']
|
257
|
+
|
258
|
+
encoding ||= 'BINARY'
|
259
|
+
|
260
|
+
LOGGER.info("suggested file encoding: #{encoding}")
|
261
|
+
|
262
|
+
options[:encoding] || encoding
|
263
|
+
end
|
264
|
+
|
265
|
+
def try_encoding_from_file(filename = nil)
|
266
|
+
return unless filename
|
267
|
+
|
268
|
+
m = nil
|
269
|
+
os = Gem::Platform.local.os
|
270
|
+
if os == 'linux'
|
271
|
+
m = `file -i #{filename}`.match(/charset=(\S+)/)
|
272
|
+
elsif os == 'darwin'
|
273
|
+
m = `file -I #{filename}`.match(/charset=(\S+)/)
|
244
274
|
end
|
245
|
-
|
275
|
+
m && m[1]
|
246
276
|
end
|
247
277
|
|
248
278
|
@settings = { :testing => false }
|
data/lib/reckon/ledger_parser.rb
CHANGED
@@ -1,4 +1,109 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# From: https://www.ledger-cli.org/3.0/doc/ledger3.html#Transactions-and-Comments
|
5
|
+
#
|
6
|
+
# The ledger file format is quite simple, but also very flexible. It supports many
|
7
|
+
# options, though typically the user can ignore most of them. They are summarized below.
|
8
|
+
#
|
9
|
+
# The initial character of each line determines what the line means, and how it should
|
10
|
+
# be interpreted. Allowable initial characters are:
|
11
|
+
#
|
12
|
+
# NUMBER
|
13
|
+
# A line beginning with a number denotes an entry. It may be followed by any
|
14
|
+
# number of lines, each beginning with whitespace, to denote the entry's account
|
15
|
+
# transactions. The format of the first line is:
|
16
|
+
#
|
17
|
+
# DATE[=EDATE] [*|!] [(CODE)] DESC
|
18
|
+
#
|
19
|
+
# If '*' appears after the date (with optional effective date), it indicates the
|
20
|
+
# entry is "cleared", which can mean whatever the user wants it to mean. If '!'
|
21
|
+
# appears after the date, it indicates d the entry is "pending"; i.e., tentatively
|
22
|
+
# cleared from the user's point of view, but not yet actually cleared. If a 'CODE'
|
23
|
+
# appears in parentheses, it may be used to indicate a check number, or the type of
|
24
|
+
# the transaction. Following these is the payee, or a description of the
|
25
|
+
# transaction.
|
26
|
+
#
|
27
|
+
# The format of each following transaction is:
|
28
|
+
#
|
29
|
+
# ACCOUNT AMOUNT [; NOTE]
|
30
|
+
#
|
31
|
+
# The 'ACCOUNT' may be surrounded by parentheses if it is a virtual transactions, or
|
32
|
+
# square brackets if it is a virtual transactions that must balance. The 'AMOUNT'
|
33
|
+
# can be followed by a per-unit transaction cost, by specifying '@ AMOUNT', or a
|
34
|
+
# complete transaction cost with '@@ AMOUNT'. Lastly, the 'NOTE' may specify an
|
35
|
+
# actual and/or effective date for the transaction by using the syntax
|
36
|
+
# '[ACTUAL_DATE]' or '[=EFFECTIVE_DATE]' or '[ACTUAL_DATE=EFFECtIVE_DATE]'.
|
37
|
+
# =
|
38
|
+
# An automated entry. A value expression must appear after the equal sign.
|
39
|
+
#
|
40
|
+
# After this initial line there should be a set of one or more transactions, just as
|
41
|
+
# if it were normal entry. If the amounts of the transactions have no commodity,
|
42
|
+
# they will be applied as modifiers to whichever real transaction is matched by the
|
43
|
+
# value expression.
|
44
|
+
# ~
|
45
|
+
# A period entry. A period expression must appear after the tilde.
|
46
|
+
#
|
47
|
+
# After this initial line there should be a set of one or more transactions, just as
|
48
|
+
# if it were normal entry.
|
49
|
+
# !
|
50
|
+
# A line beginning with an exclamation mark denotes a command directive. It must be
|
51
|
+
# immediately followed by the command word. The supported commands are:
|
52
|
+
#
|
53
|
+
# '!include'
|
54
|
+
# Include the stated ledger file.
|
55
|
+
#
|
56
|
+
# '!account'
|
57
|
+
# The account name is given is taken to be the parent of all transactions that
|
58
|
+
# follow, until '!end' is seen.
|
59
|
+
#
|
60
|
+
# '!end'
|
61
|
+
# Ends an account block.
|
62
|
+
#
|
63
|
+
# ;
|
64
|
+
# A line beginning with a colon indicates a comment, and is ignored.
|
65
|
+
# Y
|
66
|
+
# If a line begins with a capital Y, it denotes the year used for all subsequent
|
67
|
+
# entries that give a date without a year. The year should appear immediately after
|
68
|
+
# the Y, for example: 'Y2004'. This is useful at the beginning of a file, to specify
|
69
|
+
# the year for that file. If all entries specify a year, however, this command has
|
70
|
+
# no effect.
|
71
|
+
#
|
72
|
+
# P
|
73
|
+
# Specifies a historical price for a commodity. These are usually found in a pricing
|
74
|
+
# history file (see the -Q option). The syntax is:
|
75
|
+
#
|
76
|
+
# P DATE SYMBOL PRICE
|
77
|
+
#
|
78
|
+
# N SYMBOL
|
79
|
+
# Indicates that pricing information is to be ignored for a given symbol, nor will
|
80
|
+
# quotes ever be downloaded for that symbol. Useful with a home currency, such as
|
81
|
+
# the dollar ($). It is recommended that these pricing options be set in the price
|
82
|
+
# database file, which defaults to ~/.pricedb. The syntax for this command is:
|
83
|
+
#
|
84
|
+
# N SYMBOL
|
85
|
+
#
|
86
|
+
# D AMOUNT
|
87
|
+
# Specifies the default commodity to use, by specifying an amount in the expected
|
88
|
+
# format. The entry command will use this commodity as the default when none other
|
89
|
+
# can be determined. This command may be used multiple times, to set the default
|
90
|
+
# flags for different commodities; whichever is seen last is used as the default
|
91
|
+
# commodity. For example, to set US dollars as the default commodity, while also
|
92
|
+
# setting the thousands flag and decimal flag for that commodity, use:
|
93
|
+
#
|
94
|
+
# D $1,000.00
|
95
|
+
#
|
96
|
+
# C AMOUNT1 = AMOUNT2
|
97
|
+
# Specifies a commodity conversion, where the first amount is given to be equivalent
|
98
|
+
# to the second amount. The first amount should use the decimal precision desired
|
99
|
+
# during reporting:
|
100
|
+
#
|
101
|
+
# C 1.00 Kb = 1024 bytes
|
102
|
+
#
|
103
|
+
# i, o, b, h
|
104
|
+
# These four relate to timeclock support, which permits ledger to read timelog
|
105
|
+
# files. See the timeclock's documentation for more info on the syntax of its
|
106
|
+
# timelog files.
|
2
107
|
|
3
108
|
require 'rubygems'
|
4
109
|
|
@@ -8,54 +113,113 @@ module Reckon
|
|
8
113
|
attr_accessor :entries
|
9
114
|
|
10
115
|
def initialize(ledger, options = {})
|
11
|
-
@
|
116
|
+
@options = options
|
117
|
+
@date_format = options[:date_format] || '%Y-%m-%d'
|
12
118
|
parse(ledger)
|
13
119
|
end
|
14
120
|
|
15
121
|
def parse(ledger)
|
16
122
|
@entries = []
|
17
|
-
|
18
|
-
accounts = []
|
123
|
+
new_entry = {}
|
19
124
|
ledger.strip.split("\n").each do |entry|
|
20
|
-
next if entry =~ /^\s*$/ || entry =~
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
125
|
+
next if entry =~ /^\s*$/ || entry =~ /^\s*;/
|
126
|
+
|
127
|
+
# (date, type, code, description), type and code are optional
|
128
|
+
if (m = entry.match(%r{^(\d+[\d/-]+)\s+([*!])?\s*(\([^)]+\))?\s*(.*)$}))
|
129
|
+
add_entry(new_entry)
|
130
|
+
new_entry = {
|
131
|
+
date: try_parse_date(m[1]),
|
132
|
+
type: m[2] || "",
|
133
|
+
code: m[3] && m[3].tr('()', '') || "",
|
134
|
+
desc: m[4].strip,
|
135
|
+
accounts: []
|
136
|
+
}
|
137
|
+
elsif new_entry[:date] && entry =~ /^\s+/
|
138
|
+
new_entry[:accounts] << parse_account_line(entry)
|
28
139
|
else
|
29
|
-
|
30
|
-
|
31
|
-
|
140
|
+
LOGGER.info("Unknown entry type: #{entry}")
|
141
|
+
add_entry(new_entry)
|
142
|
+
new_entry = {}
|
32
143
|
end
|
33
144
|
end
|
34
|
-
|
145
|
+
add_entry(new_entry)
|
35
146
|
end
|
36
147
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
148
|
+
# roughly matches ledger csv format
|
149
|
+
def to_csv
|
150
|
+
return @entries.flat_map do |n|
|
151
|
+
n[:accounts].map do |a|
|
152
|
+
row = [
|
153
|
+
n[:date].strftime(@date_format),
|
154
|
+
n[:code],
|
155
|
+
n[:desc],
|
156
|
+
a[:name],
|
157
|
+
"", # currency (not implemented)
|
158
|
+
a[:amount],
|
159
|
+
n[:type],
|
160
|
+
"", # account comment (not implemented)
|
161
|
+
]
|
162
|
+
CSV.generate_line(row).strip
|
51
163
|
end
|
52
164
|
end
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def add_entry(entry)
|
170
|
+
return unless entry[:date] && entry[:accounts].length > 1
|
171
|
+
|
172
|
+
entry[:accounts] = balance(entry[:accounts])
|
173
|
+
@entries << entry
|
174
|
+
end
|
175
|
+
|
176
|
+
def try_parse_date(date_str)
|
177
|
+
date = Date.parse(date_str)
|
178
|
+
return nil if date.year > 9999 || date.year < 1000
|
179
|
+
|
180
|
+
date
|
181
|
+
rescue ArgumentError
|
182
|
+
nil
|
183
|
+
end
|
184
|
+
|
185
|
+
def parse_account_line(entry)
|
186
|
+
(account_name, rest) = entry.strip.split(/\s{2,}|\t+/, 2)
|
187
|
+
|
188
|
+
return {
|
189
|
+
name: account_name,
|
190
|
+
amount: clean_money("")
|
191
|
+
} if rest.nil? || rest.empty?
|
192
|
+
|
193
|
+
(value, _comment) = rest.split(/;/)
|
194
|
+
return {
|
195
|
+
name: account_name,
|
196
|
+
amount: clean_money(value || "")
|
197
|
+
}
|
198
|
+
end
|
199
|
+
|
200
|
+
def balance(accounts)
|
201
|
+
return accounts unless accounts.any? { |i| i[:amount].nil? }
|
202
|
+
|
203
|
+
sum = accounts.reduce(0) { |m, n| m + (n[:amount] || 0) }
|
204
|
+
count = 0
|
205
|
+
accounts.each do |account|
|
206
|
+
next unless account[:amount].nil?
|
207
|
+
|
208
|
+
count += 1
|
209
|
+
account[:amount] = -sum
|
210
|
+
end
|
211
|
+
if count > 1
|
212
|
+
puts "Warning: unparsable entry due to more than one missing money value."
|
213
|
+
p accounts
|
214
|
+
puts
|
215
|
+
end
|
53
216
|
|
54
217
|
accounts
|
55
218
|
end
|
56
219
|
|
57
220
|
def clean_money(money)
|
58
|
-
return nil if money.nil? || money.
|
221
|
+
return nil if money.nil? || money.empty?
|
222
|
+
|
59
223
|
money.gsub(/[^0-9.-]/, '').to_f
|
60
224
|
end
|
61
225
|
end
|