reckon 0.5.2 → 0.6.2
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 +4 -4
- data/.github/workflows/ruby.yml +50 -0
- data/.gitignore +2 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +66 -2
- data/Gemfile.lock +1 -5
- data/README.md +76 -16
- data/Rakefile +17 -1
- data/bin/reckon +6 -1
- data/lib/reckon.rb +2 -5
- data/lib/reckon/app.rb +156 -73
- data/lib/reckon/cosine_similarity.rb +91 -89
- data/lib/reckon/csv_parser.rb +8 -8
- data/lib/reckon/date_column.rb +10 -0
- data/lib/reckon/ledger_parser.rb +11 -1
- data/lib/reckon/logger.rb +4 -0
- data/lib/reckon/money.rb +48 -48
- data/lib/reckon/version.rb +1 -1
- data/reckon.gemspec +1 -2
- data/spec/integration/another_bank_example/input.csv +9 -0
- data/spec/integration/another_bank_example/output.ledger +36 -0
- data/spec/integration/another_bank_example/test_args +1 -0
- data/spec/integration/austrian_example/input.csv +13 -0
- data/spec/integration/austrian_example/output.ledger +52 -0
- data/spec/integration/austrian_example/test_args +2 -0
- data/spec/integration/bom_utf8_file/input.csv +3 -0
- data/spec/integration/bom_utf8_file/output.ledger +4 -0
- data/spec/integration/bom_utf8_file/test_args +3 -0
- data/spec/integration/broker_canada_example/input.csv +12 -0
- data/spec/integration/broker_canada_example/output.ledger +48 -0
- data/spec/integration/broker_canada_example/test_args +1 -0
- data/spec/integration/chase/account_tokens_and_regex/output.ledger +36 -0
- data/spec/integration/chase/account_tokens_and_regex/test_args +2 -0
- data/spec/integration/chase/account_tokens_and_regex/tokens.yml +16 -0
- data/spec/integration/chase/default_account_names/output.ledger +36 -0
- data/spec/integration/chase/default_account_names/test_args +3 -0
- data/spec/integration/chase/input.csv +9 -0
- data/spec/integration/chase/learn_from_existing/learn.ledger +7 -0
- data/spec/integration/chase/learn_from_existing/output.ledger +36 -0
- data/spec/integration/chase/learn_from_existing/test_args +1 -0
- data/spec/integration/chase/simple/output.ledger +36 -0
- data/spec/integration/chase/simple/test_args +1 -0
- data/spec/integration/danish_kroner_nordea_example/input.csv +6 -0
- data/spec/integration/danish_kroner_nordea_example/output.ledger +24 -0
- data/spec/integration/danish_kroner_nordea_example/test_args +1 -0
- data/spec/integration/english_date_example/input.csv +3 -0
- data/spec/integration/english_date_example/output.ledger +12 -0
- data/spec/integration/english_date_example/test_args +1 -0
- data/spec/integration/extratofake/input.csv +24 -0
- data/spec/integration/extratofake/output.ledger +92 -0
- data/spec/integration/extratofake/test_args +1 -0
- data/spec/integration/french_example/input.csv +9 -0
- data/spec/integration/french_example/output.ledger +36 -0
- data/spec/integration/french_example/test_args +2 -0
- data/spec/integration/german_date_example/input.csv +3 -0
- data/spec/integration/german_date_example/output.ledger +12 -0
- data/spec/integration/german_date_example/test_args +1 -0
- data/spec/integration/harder_date_example/input.csv +5 -0
- data/spec/integration/harder_date_example/output.ledger +20 -0
- data/spec/integration/harder_date_example/test_args +1 -0
- data/spec/integration/ing/input.csv +3 -0
- data/spec/integration/ing/output.ledger +12 -0
- data/spec/integration/ing/test_args +1 -0
- data/spec/integration/intuit_mint_example/input.csv +7 -0
- data/spec/integration/intuit_mint_example/output.ledger +28 -0
- data/spec/integration/intuit_mint_example/test_args +1 -0
- data/spec/integration/invalid_header_example/input.csv +6 -0
- data/spec/integration/invalid_header_example/output.ledger +8 -0
- data/spec/integration/invalid_header_example/test_args +1 -0
- data/spec/integration/inversed_credit_card/input.csv +16 -0
- data/spec/integration/inversed_credit_card/output.ledger +64 -0
- data/spec/integration/inversed_credit_card/test_args +1 -0
- data/spec/integration/nationwide/input.csv +4 -0
- data/spec/integration/nationwide/output.ledger +16 -0
- data/spec/integration/nationwide/test_args +1 -0
- data/spec/integration/regression/issue_51_account_tokens/input.csv +8 -0
- data/spec/integration/regression/issue_51_account_tokens/output.ledger +32 -0
- data/spec/integration/regression/issue_51_account_tokens/test_args +4 -0
- data/spec/integration/regression/issue_51_account_tokens/tokens.yml +9 -0
- data/spec/integration/regression/issue_64_date_column/input.csv +3 -0
- data/spec/integration/regression/issue_64_date_column/output.ledger +8 -0
- data/spec/integration/regression/issue_64_date_column/test_args +1 -0
- data/spec/integration/regression/issue_73_account_token_matching/input.csv +2 -0
- data/spec/integration/regression/issue_73_account_token_matching/output.ledger +4 -0
- data/spec/integration/regression/issue_73_account_token_matching/test_args +6 -0
- data/spec/integration/regression/issue_73_account_token_matching/tokens.yml +8 -0
- data/spec/integration/regression/issue_85_date_example/input.csv +2 -0
- data/spec/integration/regression/issue_85_date_example/output.ledger +8 -0
- data/spec/integration/regression/issue_85_date_example/test_args +1 -0
- data/spec/integration/spanish_date_example/input.csv +3 -0
- data/spec/integration/spanish_date_example/output.ledger +12 -0
- data/spec/integration/spanish_date_example/test_args +1 -0
- data/spec/integration/suntrust/input.csv +7 -0
- data/spec/integration/suntrust/output.ledger +28 -0
- data/spec/integration/suntrust/test_args +1 -0
- data/spec/integration/test.sh +82 -0
- data/spec/integration/test_money_column/input.csv +3 -0
- data/spec/integration/test_money_column/output.ledger +8 -0
- data/spec/integration/test_money_column/test_args +1 -0
- data/spec/integration/two_money_columns/input.csv +5 -0
- data/spec/integration/two_money_columns/output.ledger +20 -0
- data/spec/integration/two_money_columns/test_args +1 -0
- data/spec/integration/yyyymmdd_date_example/input.csv +1 -0
- data/spec/integration/yyyymmdd_date_example/output.ledger +4 -0
- data/spec/integration/yyyymmdd_date_example/test_args +1 -0
- data/spec/reckon/app_spec.rb +18 -2
- data/spec/reckon/csv_parser_spec.rb +5 -0
- data/spec/reckon/ledger_parser_spec.rb +42 -5
- data/spec/reckon/money_column_spec.rb +24 -24
- data/spec/reckon/money_spec.rb +13 -32
- metadata +94 -21
- data/.travis.yml +0 -13
|
@@ -3,118 +3,120 @@ require 'set'
|
|
|
3
3
|
|
|
4
4
|
# Implementation of consine similarity using TF-IDF for vectorization.
|
|
5
5
|
# Used to suggest which account a transaction should be assigned to
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def add_document(account, doc)
|
|
14
|
-
tokenize(doc).each do |n|
|
|
15
|
-
(token, count) = n
|
|
16
|
-
|
|
17
|
-
@tokens[token] ||= {}
|
|
18
|
-
@tokens[token][account] ||= 0
|
|
19
|
-
@tokens[token][account] += count
|
|
20
|
-
@accounts[account] += count
|
|
6
|
+
module Reckon
|
|
7
|
+
class CosineSimilarity
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
@tokens = {}
|
|
11
|
+
@accounts = Hash.new(0)
|
|
21
12
|
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# find most similar documents to query
|
|
25
|
-
def find_similar(query)
|
|
26
|
-
(query_scores, corpus_scores) = td_idf_scores_for(query)
|
|
27
13
|
|
|
28
|
-
|
|
14
|
+
def add_document(account, doc)
|
|
15
|
+
tokenize(doc).each do |n|
|
|
16
|
+
(token, count) = n
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
@tokens[token] ||= {}
|
|
19
|
+
@tokens[token][account] ||= 0
|
|
20
|
+
@tokens[token][account] += count
|
|
21
|
+
@accounts[account] += count
|
|
22
|
+
end
|
|
23
|
+
end
|
|
33
24
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# see https://en.wikipedia.org/wiki/Cosine_similarity
|
|
38
|
-
# cos(theta) = (A . B) / (||A|| ||B||)
|
|
39
|
-
# where A . B is the "dot product" and ||A|| is the magnitude of A
|
|
40
|
-
# ruby has the 'matrix' library we can use to do these calculations.
|
|
41
|
-
{
|
|
42
|
-
similarity: acct_query_dp / (acct_vector.magnitude * query_vector.magnitude),
|
|
43
|
-
account: account,
|
|
44
|
-
}
|
|
45
|
-
end.select { |n| n[:similarity] > 0 }.sort_by { |n| -n[:similarity] }
|
|
25
|
+
# find most similar documents to query
|
|
26
|
+
def find_similar(query)
|
|
27
|
+
(query_scores, corpus_scores) = td_idf_scores_for(query)
|
|
46
28
|
|
|
47
|
-
|
|
29
|
+
query_vector = Vector.elements(query_scores, false)
|
|
48
30
|
|
|
49
|
-
|
|
50
|
-
|
|
31
|
+
# For each doc, calculate the similarity to the query
|
|
32
|
+
suggestions = corpus_scores.map do |account, scores|
|
|
33
|
+
acct_vector = Vector.elements(scores, false)
|
|
51
34
|
|
|
52
|
-
|
|
35
|
+
acct_query_dp = acct_vector.inner_product(query_vector)
|
|
36
|
+
# similarity is a float between 1 and -1, where 1 is exactly the same and -1 is
|
|
37
|
+
# exactly opposite
|
|
38
|
+
# see https://en.wikipedia.org/wiki/Cosine_similarity
|
|
39
|
+
# cos(theta) = (A . B) / (||A|| ||B||)
|
|
40
|
+
# where A . B is the "dot product" and ||A|| is the magnitude of A
|
|
41
|
+
# ruby has the 'matrix' library we can use to do these calculations.
|
|
42
|
+
{
|
|
43
|
+
similarity: acct_query_dp / (acct_vector.magnitude * query_vector.magnitude),
|
|
44
|
+
account: account,
|
|
45
|
+
}
|
|
46
|
+
end.select { |n| n[:similarity] > 0 }.sort_by { |n| -n[:similarity] }
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
query_tokens = tokenize(query)
|
|
56
|
-
corpus = Set.new
|
|
57
|
-
corpus_scores = {}
|
|
58
|
-
query_scores = []
|
|
59
|
-
num_docs = @accounts.length
|
|
48
|
+
LOGGER.info "most similar accounts: #{suggestions}"
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
(token, _count) = n
|
|
63
|
-
next unless @tokens[token]
|
|
64
|
-
corpus = corpus.union(Set.new(@tokens[token].keys))
|
|
50
|
+
return suggestions
|
|
65
51
|
end
|
|
66
52
|
|
|
67
|
-
|
|
68
|
-
(token, count) = n
|
|
53
|
+
private
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
|
|
55
|
+
def td_idf_scores_for(query)
|
|
56
|
+
query_tokens = tokenize(query)
|
|
57
|
+
corpus = Set.new
|
|
58
|
+
corpus_scores = {}
|
|
59
|
+
query_scores = []
|
|
60
|
+
num_docs = @accounts.length
|
|
61
|
+
|
|
62
|
+
query_tokens.each do |n|
|
|
63
|
+
(token, _count) = n
|
|
64
|
+
next unless @tokens[token]
|
|
65
|
+
corpus = corpus.union(Set.new(@tokens[token].keys))
|
|
66
|
+
end
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
count,
|
|
76
|
-
query_tokens.length,
|
|
77
|
-
@tokens[token].length,
|
|
78
|
-
num_docs
|
|
79
|
-
)
|
|
68
|
+
query_tokens.each do |n|
|
|
69
|
+
(token, count) = n
|
|
80
70
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
corpus_scores[account] ||= []
|
|
71
|
+
# if no other docs have token, ignore it
|
|
72
|
+
next unless @tokens[token]
|
|
84
73
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
74
|
+
## First, calculate scores for our query as we're building scores for the corpus
|
|
75
|
+
query_scores << calc_tf_idf(
|
|
76
|
+
count,
|
|
77
|
+
query_tokens.length,
|
|
78
|
+
@tokens[token].length,
|
|
89
79
|
num_docs
|
|
90
80
|
)
|
|
81
|
+
|
|
82
|
+
## Next, calculate for the corpus, where our "account" is a document
|
|
83
|
+
corpus.each do |account|
|
|
84
|
+
corpus_scores[account] ||= []
|
|
85
|
+
|
|
86
|
+
corpus_scores[account] << calc_tf_idf(
|
|
87
|
+
(@tokens[token][account] || 0),
|
|
88
|
+
@accounts[account].to_f,
|
|
89
|
+
@tokens[token].length.to_f,
|
|
90
|
+
num_docs
|
|
91
|
+
)
|
|
92
|
+
end
|
|
91
93
|
end
|
|
94
|
+
[query_scores, corpus_scores]
|
|
92
95
|
end
|
|
93
|
-
[query_scores, corpus_scores]
|
|
94
|
-
end
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
def calc_tf_idf(token_count, num_words_in_doc, df, num_docs)
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
# tf(t,d) = count of t in d / number of words in d
|
|
100
|
+
tf = token_count / num_words_in_doc.to_f
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
# smooth idf weight
|
|
103
|
+
# see https://en.wikipedia.org/wiki/Tf%E2%80%93idf#Inverse_document_frequency_2
|
|
104
|
+
# df(t) = num of documents with term t in them
|
|
105
|
+
# idf(t) = log(N/(1 + df )) + 1
|
|
106
|
+
idf = Math.log(num_docs.to_f / (1 + df)) + 1
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
tf * idf
|
|
109
|
+
end
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
end
|
|
111
|
+
def tokenize(str)
|
|
112
|
+
mk_tokens(str).inject(Hash.new(0)) do |memo, n|
|
|
113
|
+
memo[n] += 1
|
|
114
|
+
memo
|
|
115
|
+
end.to_a
|
|
116
|
+
end
|
|
117
117
|
|
|
118
|
-
def mk_tokens(str)
|
|
119
|
-
|
|
118
|
+
def mk_tokens(str)
|
|
119
|
+
str.downcase.tr(';', ' ').tr("'", '').split(/[^a-z0-9.]+/)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
120
122
|
end
|
data/lib/reckon/csv_parser.rb
CHANGED
|
@@ -53,7 +53,12 @@ module Reckon
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def description_for(index)
|
|
56
|
-
description_column_indices.map { |i| columns[i][index]
|
|
56
|
+
description_column_indices.map { |i| columns[i][index].to_s.strip }
|
|
57
|
+
.reject(&:empty?)
|
|
58
|
+
.join("; ")
|
|
59
|
+
.squeeze(" ")
|
|
60
|
+
.gsub(/(;\s+){2,}/, '')
|
|
61
|
+
.strip
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
def row(index)
|
|
@@ -84,12 +89,7 @@ module Reckon
|
|
|
84
89
|
money_score += Money::likelihood( entry )
|
|
85
90
|
possible_neg_money_count += 1 if entry =~ /^\$?[\-\(]\$?\d+/
|
|
86
91
|
possible_pos_money_count += 1 if entry =~ /^\+?\$?\+?\d+/
|
|
87
|
-
date_score +=
|
|
88
|
-
date_score += 5 if entry =~ /^[\-\/\.\d:\[\]]+$/
|
|
89
|
-
date_score += entry.gsub(/[^\-\/\.\d:\[\]]/, '').length if entry.gsub(/[^\-\/\.\d:\[\]]/, '').length > 3
|
|
90
|
-
date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length
|
|
91
|
-
date_score += 30 if entry =~ /^\d+[:\/\.-]\d+[:\/\.-]\d+([ :]\d+[:\/\.]\d+)?$/
|
|
92
|
-
date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
|
|
92
|
+
date_score += DateColumn.likelihood(entry)
|
|
93
93
|
|
|
94
94
|
# Try to determine if this is a balance column
|
|
95
95
|
entry_as_num = entry.gsub(/[^\-\d\.]/, '').to_f
|
|
@@ -163,7 +163,7 @@ module Reckon
|
|
|
163
163
|
results = evaluate_columns(columns)
|
|
164
164
|
|
|
165
165
|
if options[:money_column]
|
|
166
|
-
self.money_column_indices = [
|
|
166
|
+
self.money_column_indices = [options[:money_column] - 1]
|
|
167
167
|
else
|
|
168
168
|
self.money_column_indices = results.select { |n| n[:is_money_column] }.map { |n| n[:index] }
|
|
169
169
|
if self.money_column_indices.length == 1
|
data/lib/reckon/date_column.rb
CHANGED
|
@@ -56,5 +56,15 @@ module Reckon
|
|
|
56
56
|
date.iso8601
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def self.likelihood(entry)
|
|
60
|
+
date_score = 0
|
|
61
|
+
date_score += 10 if entry =~ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i
|
|
62
|
+
date_score += 5 if entry =~ /^[\-\/\.\d:\[\]]+$/
|
|
63
|
+
date_score += entry.gsub(/[^\-\/\.\d:\[\]]/, '').length if entry.gsub(/[^\-\/\.\d:\[\]]/, '').length > 3
|
|
64
|
+
date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length
|
|
65
|
+
date_score += 30 if entry =~ /^\d+[:\/\.-]\d+[:\/\.-]\d+([ :]\d+[:\/\.]\d+)?$/
|
|
66
|
+
date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
|
|
67
|
+
return date_score
|
|
68
|
+
end
|
|
59
69
|
end
|
|
60
70
|
end
|
data/lib/reckon/ledger_parser.rb
CHANGED
|
@@ -121,8 +121,14 @@ module Reckon
|
|
|
121
121
|
def parse(ledger)
|
|
122
122
|
@entries = []
|
|
123
123
|
new_entry = {}
|
|
124
|
+
in_comment = false
|
|
125
|
+
comment_chars = ';#%*|'
|
|
124
126
|
ledger.strip.split("\n").each do |entry|
|
|
125
|
-
|
|
127
|
+
# strip comment lines
|
|
128
|
+
in_comment = true if entry == 'comment'
|
|
129
|
+
in_comment = false if entry == 'end comment'
|
|
130
|
+
next if in_comment
|
|
131
|
+
next if entry =~ /^\s*[#{comment_chars}]/
|
|
126
132
|
|
|
127
133
|
# (date, type, code, description), type and code are optional
|
|
128
134
|
if (m = entry.match(%r{^(\d+[\d/-]+)\s+([*!])?\s*(\([^)]+\))?\s*(.*)$}))
|
|
@@ -134,7 +140,11 @@ module Reckon
|
|
|
134
140
|
desc: m[4].strip,
|
|
135
141
|
accounts: []
|
|
136
142
|
}
|
|
143
|
+
elsif entry =~ /^\s*$/ && new_entry[:date]
|
|
144
|
+
add_entry(new_entry)
|
|
145
|
+
new_entry = {}
|
|
137
146
|
elsif new_entry[:date] && entry =~ /^\s+/
|
|
147
|
+
LOGGER.info("Adding new account #{entry}")
|
|
138
148
|
new_entry[:accounts] << parse_account_line(entry)
|
|
139
149
|
else
|
|
140
150
|
LOGGER.info("Unknown entry type: #{entry}")
|
data/lib/reckon/money.rb
CHANGED
|
@@ -5,12 +5,13 @@ module Reckon
|
|
|
5
5
|
class Money
|
|
6
6
|
include Comparable
|
|
7
7
|
attr_accessor :amount, :currency, :suffixed
|
|
8
|
-
def initialize(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
def initialize(amount, options = {})
|
|
9
|
+
@options = options
|
|
10
|
+
@amount_raw = amount
|
|
11
|
+
@raw = options[:raw]
|
|
12
|
+
|
|
13
|
+
@amount = parse(amount, options)
|
|
14
|
+
@amount = -@amount if options[:inverse]
|
|
14
15
|
@currency = options[:currency] || "$"
|
|
15
16
|
@suffixed = options[:suffixed]
|
|
16
17
|
end
|
|
@@ -19,11 +20,19 @@ module Reckon
|
|
|
19
20
|
return @amount
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
def to_s
|
|
24
|
+
return @options[:raw] ? "#{@amount_raw} | #{@amount}" : @amount
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# unary minus
|
|
28
|
+
# ex
|
|
29
|
+
# m = Money.new
|
|
30
|
+
# -m
|
|
22
31
|
def -@
|
|
23
|
-
Money.new(
|
|
32
|
+
Money.new(-@amount, :currency => @currency, :suffixed => @suffixed)
|
|
24
33
|
end
|
|
25
34
|
|
|
26
|
-
def <=>(
|
|
35
|
+
def <=>(mon)
|
|
27
36
|
other_amount = mon.to_f
|
|
28
37
|
if @amount < other_amount
|
|
29
38
|
-1
|
|
@@ -34,7 +43,13 @@ module Reckon
|
|
|
34
43
|
end
|
|
35
44
|
end
|
|
36
45
|
|
|
37
|
-
def pretty(
|
|
46
|
+
def pretty(negate = false)
|
|
47
|
+
if @raw
|
|
48
|
+
return @amount_raw unless negate
|
|
49
|
+
|
|
50
|
+
return @amount_raw[0] == '-' ? @amount_raw[1..-1] : "-#{@amount_raw}"
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
if @suffixed
|
|
39
54
|
(@amount >= 0 ? " " : "") + sprintf("%0.2f #{@currency}", @amount * (negate ? -1 : 1))
|
|
40
55
|
else
|
|
@@ -42,34 +57,20 @@ module Reckon
|
|
|
42
57
|
end
|
|
43
58
|
end
|
|
44
59
|
|
|
45
|
-
def
|
|
60
|
+
def parse(value, options = {})
|
|
61
|
+
value = value.to_s
|
|
46
62
|
# Empty string is treated as money with value 0
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
value = value.
|
|
52
|
-
value = value.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
any_number_regex = /^(.*?)([\d\.]+)/
|
|
56
|
-
|
|
57
|
-
# Prefer matching the money_format, match any number otherwise
|
|
58
|
-
m = value.match( money_format_regex ) ||
|
|
59
|
-
value.match( any_number_regex )
|
|
60
|
-
if m
|
|
61
|
-
amount = m[2].to_f
|
|
62
|
-
# Check whether the money had a - or (, which indicates negative amounts
|
|
63
|
-
if (m[1].match( /^[\(-]/ ) || m[1].match( /-$/ ))
|
|
64
|
-
amount *= -1
|
|
65
|
-
end
|
|
66
|
-
return Money.new( amount, options )
|
|
67
|
-
else
|
|
68
|
-
return nil
|
|
69
|
-
end
|
|
63
|
+
return value.to_f if value.to_s.empty?
|
|
64
|
+
|
|
65
|
+
invert = value.match(/^\(.*\)$/)
|
|
66
|
+
value = value.gsub(/[^0-9,.-]/, '')
|
|
67
|
+
value = value.tr('.', '').tr(',', '.') if options[:comma_separates_cents]
|
|
68
|
+
value = value.tr(',', '')
|
|
69
|
+
value = value.to_f
|
|
70
|
+
return invert ? -value : value
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
def Money::likelihood(
|
|
73
|
+
def Money::likelihood(entry)
|
|
73
74
|
money_score = 0
|
|
74
75
|
# digits separated by , or . with no more than 2 trailing digits
|
|
75
76
|
money_score += 40 if entry.match(/\d+[,.]\d{2}[^\d]*$/)
|
|
@@ -83,31 +84,30 @@ module Reckon
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
class MoneyColumn < Array
|
|
86
|
-
def initialize(
|
|
87
|
-
arr.each { |str|
|
|
87
|
+
def initialize(arr = [], options = {})
|
|
88
|
+
arr.each { |str| push(Money.new(str, options)) }
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def positive?
|
|
91
|
-
|
|
92
|
-
return false if money < 0
|
|
92
|
+
each do |money|
|
|
93
|
+
return false if money && money < 0
|
|
93
94
|
end
|
|
94
95
|
true
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
def merge!(
|
|
98
|
+
def merge!(other_column)
|
|
98
99
|
invert = false
|
|
99
|
-
invert = true if
|
|
100
|
-
|
|
100
|
+
invert = true if positive? && other_column.positive?
|
|
101
|
+
each_with_index do |mon, i|
|
|
101
102
|
other = other_column[i]
|
|
102
|
-
return nil if
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
elsif mon == 0.00 && other != 0.00
|
|
103
|
+
return nil if !mon || !other
|
|
104
|
+
|
|
105
|
+
if mon != 0.0 && other == 0.0
|
|
106
|
+
self[i] = -mon if invert
|
|
107
|
+
elsif mon == 0.0 && other != 0.0
|
|
108
108
|
self[i] = other
|
|
109
109
|
else
|
|
110
|
-
|
|
110
|
+
self[i] = Money.new(0)
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
self
|
data/lib/reckon/version.rb
CHANGED
data/reckon.gemspec
CHANGED
|
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
|
|
|
13
13
|
|
|
14
14
|
s.files = `git ls-files`.split("\n")
|
|
15
15
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
16
|
-
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
|
16
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
17
17
|
s.require_paths = ["lib"]
|
|
18
18
|
|
|
19
19
|
s.add_development_dependency "rspec", ">= 1.2.9"
|
|
@@ -21,6 +21,5 @@ Gem::Specification.new do |s|
|
|
|
21
21
|
s.add_development_dependency "rantly", "= 1.2.0"
|
|
22
22
|
s.add_runtime_dependency "chronic", ">= 0.3.0"
|
|
23
23
|
s.add_runtime_dependency "highline", ">= 1.5.2"
|
|
24
|
-
s.add_runtime_dependency "terminal-table", ">= 1.4.2"
|
|
25
24
|
s.add_runtime_dependency "rchardet", ">= 1.8.0"
|
|
26
25
|
end
|