hledger-forecast 2.0.0 → 3.0.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 +4 -4
- data/.github/workflows/{test.yml → ci.yml} +18 -10
- data/.github/workflows/publish_ruby_gem.yml +24 -0
- data/.github/workflows/release.yml +12 -13
- data/.mise.toml +2 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +1 -0
- data/README.md +149 -119
- data/example.csv +15 -15
- data/example.journal +17 -18
- data/hledger-forecast.gemspec +20 -18
- data/lib/hledger_forecast/calculator.rb +7 -15
- data/lib/hledger_forecast/cli.rb +98 -71
- data/lib/hledger_forecast/comparator.rb +12 -11
- data/lib/hledger_forecast/forecast.rb +29 -0
- data/lib/hledger_forecast/formatter.rb +13 -15
- data/lib/hledger_forecast/generator.rb +32 -72
- data/lib/hledger_forecast/settings.rb +34 -47
- data/lib/hledger_forecast/summarizer.rb +34 -55
- data/lib/hledger_forecast/summarizer_formatter.rb +75 -78
- data/lib/hledger_forecast/transaction.rb +63 -0
- data/lib/hledger_forecast/transactions/default.rb +45 -72
- data/lib/hledger_forecast/version.rb +1 -1
- data/lib/hledger_forecast.rb +21 -22
- data/spec/calculator_spec.rb +45 -0
- data/spec/cli_spec.rb +19 -17
- data/spec/compare_spec.rb +16 -14
- data/spec/computed_amounts_spec.rb +7 -7
- data/spec/custom_spec.rb +9 -9
- data/spec/formatter_spec.rb +51 -0
- data/spec/half-yearly_spec.rb +5 -5
- data/spec/monthly_end_date_spec.rb +6 -6
- data/spec/monthly_end_date_transaction_spec.rb +10 -10
- data/spec/monthly_spec.rb +7 -7
- data/spec/once_spec.rb +5 -5
- data/spec/quarterly_spec.rb +5 -5
- data/spec/settings_spec.rb +101 -0
- data/spec/stubs/forecast.csv +4 -4
- data/spec/summarizer_spec.rb +28 -33
- data/spec/tags_spec.rb +92 -0
- data/spec/verbose_output_spec.rb +8 -8
- data/spec/yearly_spec.rb +5 -5
- metadata +49 -13
- data/lib/hledger_forecast/transactions/modifiers.rb +0 -90
- data/lib/hledger_forecast/transactions/trackers.rb +0 -88
- data/lib/hledger_forecast/utilities.rb +0 -14
- data/spec/track_spec.rb +0 -105
data/hledger-forecast.gemspec
CHANGED
|
@@ -1,30 +1,32 @@
|
|
|
1
|
-
lib = File.expand_path(
|
|
1
|
+
lib = File.expand_path("lib", __dir__)
|
|
2
2
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
3
|
|
|
4
|
-
require
|
|
4
|
+
require "hledger_forecast/version"
|
|
5
5
|
|
|
6
6
|
Gem::Specification.new do |s|
|
|
7
|
-
s.required_ruby_version =
|
|
8
|
-
s.name
|
|
9
|
-
s.version
|
|
10
|
-
s.authors
|
|
11
|
-
s.summary
|
|
12
|
-
s.description =
|
|
13
|
-
s.email
|
|
14
|
-
s.homepage
|
|
15
|
-
s.license
|
|
7
|
+
s.required_ruby_version = "~> 3.3"
|
|
8
|
+
s.name = "hledger-forecast"
|
|
9
|
+
s.version = HledgerForecast::VERSION
|
|
10
|
+
s.authors = ["Oli Morris"]
|
|
11
|
+
s.summary = "An extended wrapper around hledger's forecasting functionality"
|
|
12
|
+
s.description = "Use a CSV file for improved forecasting with hledger"
|
|
13
|
+
s.email = "olimorris@users.noreply.github.com"
|
|
14
|
+
s.homepage = "https://github.com/olimorris/hledger-forecast"
|
|
15
|
+
s.license = "MIT"
|
|
16
16
|
|
|
17
|
-
s.add_dependency
|
|
18
|
-
s.add_dependency
|
|
19
|
-
s.add_dependency
|
|
20
|
-
s.add_dependency
|
|
21
|
-
s.add_dependency
|
|
22
|
-
s.
|
|
17
|
+
s.add_dependency("abbrev", "~> 0.1")
|
|
18
|
+
s.add_dependency("csv", "~> 3.0")
|
|
19
|
+
s.add_dependency("colorize", "~> 0.8.1")
|
|
20
|
+
s.add_dependency("dentaku", "~> 3.5.1")
|
|
21
|
+
s.add_dependency("highline", "~> 2.1.0")
|
|
22
|
+
s.add_dependency("money", "~> 6.16.0")
|
|
23
|
+
s.add_dependency("terminal-table", "~> 3.0.2")
|
|
24
|
+
s.add_development_dependency("rspec", "~> 3.12")
|
|
23
25
|
|
|
24
26
|
s.files = `git ls-files`.split("\n")
|
|
25
27
|
s.test_files = `git ls-files -- spec/*`.split("\n")
|
|
26
28
|
s.executables = `git ls-files -- bin/*`.split("\n").map do |f|
|
|
27
29
|
File.basename(f)
|
|
28
30
|
end
|
|
29
|
-
s.require_paths = [
|
|
31
|
+
s.require_paths = ["lib"]
|
|
30
32
|
end
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def initialize
|
|
5
|
-
@calculator = Dentaku::Calculator.new
|
|
6
|
-
end
|
|
2
|
+
module Calculator
|
|
3
|
+
@calc = Dentaku::Calculator.new
|
|
7
4
|
|
|
8
|
-
def evaluate(amount)
|
|
5
|
+
def self.evaluate(amount)
|
|
9
6
|
return amount.to_f unless amount.is_a?(String)
|
|
10
7
|
|
|
11
|
-
@
|
|
8
|
+
@calc.evaluate(amount.slice(1..-1))
|
|
12
9
|
end
|
|
13
10
|
|
|
14
|
-
def evaluate_date(from, to)
|
|
15
|
-
|
|
16
|
-
return to if to.is_a?(Date)
|
|
17
|
-
|
|
18
|
-
return Date.parse(to)
|
|
19
|
-
end
|
|
11
|
+
def self.evaluate_date(from, to)
|
|
12
|
+
return Date.parse(to) unless to.start_with?("=")
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
(from >> @calculator.evaluate(to.slice(1..-1))) - 1
|
|
14
|
+
(from >> @calc.evaluate(to.slice(1..-1))) - 1
|
|
23
15
|
end
|
|
24
16
|
end
|
|
25
17
|
end
|
data/lib/hledger_forecast/cli.rb
CHANGED
|
@@ -4,14 +4,14 @@ module HledgerForecast
|
|
|
4
4
|
class Cli
|
|
5
5
|
def self.run(command, options)
|
|
6
6
|
case command
|
|
7
|
-
when
|
|
7
|
+
when "generate"
|
|
8
8
|
generate(options)
|
|
9
|
-
when
|
|
9
|
+
when "summarize"
|
|
10
10
|
summarize(options)
|
|
11
|
-
when
|
|
11
|
+
when "compare"
|
|
12
12
|
compare(options)
|
|
13
13
|
else
|
|
14
|
-
puts
|
|
14
|
+
puts("Unknown command: #{command}")
|
|
15
15
|
exit(1)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -22,49 +22,49 @@ module HledgerForecast
|
|
|
22
22
|
|
|
23
23
|
global = OptionParser.new do |opts|
|
|
24
24
|
opts.banner = "Usage: hledger-forecast [command] [options]"
|
|
25
|
-
opts.separator
|
|
26
|
-
opts.separator
|
|
27
|
-
opts.separator
|
|
28
|
-
opts.separator
|
|
29
|
-
opts.separator
|
|
30
|
-
opts.separator
|
|
31
|
-
opts.separator
|
|
25
|
+
opts.separator("")
|
|
26
|
+
opts.separator("Commands:")
|
|
27
|
+
opts.separator(" generate Generate a forecast from a file")
|
|
28
|
+
opts.separator(" summarize Summarize the forecast file and output to the terminal")
|
|
29
|
+
opts.separator(" compare Compare and highlight the differences between two CSV files")
|
|
30
|
+
opts.separator("")
|
|
31
|
+
opts.separator("Options:")
|
|
32
32
|
|
|
33
33
|
opts.on_tail("-h", "--help", "Show this help message") do
|
|
34
|
-
puts
|
|
34
|
+
puts(opts)
|
|
35
35
|
exit
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
opts.on_tail("-v", "--version", "Show the installed version") do
|
|
39
|
-
puts
|
|
39
|
+
puts(VERSION)
|
|
40
40
|
exit
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
if args.empty?
|
|
45
|
-
puts
|
|
45
|
+
puts(global)
|
|
46
46
|
exit(1)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
begin
|
|
50
50
|
global.order!(args)
|
|
51
|
-
command = args.shift ||
|
|
51
|
+
command = args.shift || "generate"
|
|
52
52
|
rescue OptionParser::InvalidOption => e
|
|
53
|
-
puts
|
|
54
|
-
puts
|
|
53
|
+
puts(e)
|
|
54
|
+
puts(global)
|
|
55
55
|
exit(1)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
case command
|
|
59
|
-
when
|
|
59
|
+
when "generate"
|
|
60
60
|
options = parse_generate_options(args)
|
|
61
|
-
when
|
|
61
|
+
when "summarize"
|
|
62
62
|
options = parse_summarize_options(args)
|
|
63
|
-
when
|
|
63
|
+
when "compare"
|
|
64
64
|
options = parse_compare_options(args)
|
|
65
65
|
else
|
|
66
|
-
puts
|
|
67
|
-
puts
|
|
66
|
+
puts("Unknown command: #{command}")
|
|
67
|
+
puts(global)
|
|
68
68
|
exit(1)
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -76,41 +76,50 @@ module HledgerForecast
|
|
|
76
76
|
|
|
77
77
|
global = OptionParser.new do |opts|
|
|
78
78
|
opts.banner = "Usage: hledger-forecast generate [options]"
|
|
79
|
-
opts.separator
|
|
79
|
+
opts.separator("")
|
|
80
80
|
|
|
81
|
-
opts.on(
|
|
82
|
-
|
|
81
|
+
opts.on(
|
|
82
|
+
"-f",
|
|
83
|
+
"--forecast FILE",
|
|
84
|
+
"The path to the FORECAST csv file to generate from"
|
|
85
|
+
) do |file|
|
|
83
86
|
options[:forecast_file] = file
|
|
84
|
-
options[:output_file] ||= file.sub(
|
|
87
|
+
options[:output_file] ||= file.sub("csv", "journal")
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
opts.on(
|
|
88
|
-
|
|
90
|
+
opts.on(
|
|
91
|
+
"-o",
|
|
92
|
+
"--output-file FILE",
|
|
93
|
+
"The path to the OUTPUT file to create"
|
|
94
|
+
) do |file|
|
|
89
95
|
options[:output_file] = file
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
opts.on(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
opts.on("-v", "--verbose",
|
|
98
|
-
"Do not group transactions in the output file") do
|
|
98
|
+
opts.on(
|
|
99
|
+
"-v",
|
|
100
|
+
"--verbose",
|
|
101
|
+
"Do not group transactions in the output file"
|
|
102
|
+
) do
|
|
99
103
|
options[:verbose] = true
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
opts.on(
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
opts.on(
|
|
107
|
+
"-t",
|
|
108
|
+
"--tags TAGS",
|
|
109
|
+
"Only include transactions with given tags (comma-separated)"
|
|
110
|
+
) do |tags|
|
|
111
|
+
options[:tags] = tags.split(",").map(&:strip)
|
|
105
112
|
end
|
|
106
113
|
|
|
107
|
-
opts.on(
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
opts.on(
|
|
115
|
+
"--force",
|
|
116
|
+
"Force an overwrite of the output file"
|
|
117
|
+
) do
|
|
118
|
+
options[:force] = true
|
|
110
119
|
end
|
|
111
120
|
|
|
112
121
|
opts.on_tail("-h", "--help", "Show this help message") do
|
|
113
|
-
puts
|
|
122
|
+
puts(opts)
|
|
114
123
|
exit
|
|
115
124
|
end
|
|
116
125
|
end
|
|
@@ -118,13 +127,13 @@ module HledgerForecast
|
|
|
118
127
|
begin
|
|
119
128
|
global.parse!(args)
|
|
120
129
|
rescue OptionParser::InvalidOption => e
|
|
121
|
-
puts
|
|
122
|
-
puts
|
|
130
|
+
puts(e)
|
|
131
|
+
puts(global)
|
|
123
132
|
exit(1)
|
|
124
133
|
end
|
|
125
134
|
|
|
126
135
|
if options.empty?
|
|
127
|
-
puts
|
|
136
|
+
puts(global)
|
|
128
137
|
exit(1)
|
|
129
138
|
end
|
|
130
139
|
|
|
@@ -140,21 +149,39 @@ module HledgerForecast
|
|
|
140
149
|
|
|
141
150
|
global = OptionParser.new do |opts|
|
|
142
151
|
opts.banner = "Usage: hledger-forecast summarize [options]"
|
|
143
|
-
opts.separator
|
|
152
|
+
opts.separator("")
|
|
144
153
|
|
|
145
|
-
opts.on(
|
|
146
|
-
|
|
154
|
+
opts.on(
|
|
155
|
+
"-f",
|
|
156
|
+
"--forecast FILE",
|
|
157
|
+
"The path to the FORECAST csv file to summarize"
|
|
158
|
+
) do |file|
|
|
147
159
|
options[:forecast_file] = file
|
|
148
160
|
end
|
|
149
161
|
|
|
150
|
-
opts
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
opts
|
|
163
|
+
.on(
|
|
164
|
+
"-r",
|
|
165
|
+
"--roll-up PERIOD",
|
|
166
|
+
"The period to roll-up your forecasts into. One of:",
|
|
167
|
+
"[yearly], [half-yearly], [quarterly], [monthly], [weekly], [daily]"
|
|
168
|
+
) do |rollup|
|
|
169
|
+
options[:roll_up] = rollup
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
opts.on(
|
|
173
|
+
"-t",
|
|
174
|
+
"--tags TAGS",
|
|
175
|
+
"Only include transactions with given tags (comma-separated)"
|
|
176
|
+
) do |tags|
|
|
177
|
+
options[:tags] = tags.split(",").map(&:strip)
|
|
154
178
|
end
|
|
155
179
|
|
|
156
|
-
opts.on(
|
|
157
|
-
|
|
180
|
+
opts.on(
|
|
181
|
+
"-v",
|
|
182
|
+
"--verbose",
|
|
183
|
+
"Show additional information in the summary"
|
|
184
|
+
) do |_|
|
|
158
185
|
options[:verbose] = true
|
|
159
186
|
end
|
|
160
187
|
|
|
@@ -176,7 +203,7 @@ module HledgerForecast
|
|
|
176
203
|
# end
|
|
177
204
|
|
|
178
205
|
opts.on_tail("-h", "--help", "Show this help message") do
|
|
179
|
-
puts
|
|
206
|
+
puts(opts)
|
|
180
207
|
exit
|
|
181
208
|
end
|
|
182
209
|
end
|
|
@@ -184,13 +211,13 @@ module HledgerForecast
|
|
|
184
211
|
begin
|
|
185
212
|
global.parse!(args)
|
|
186
213
|
rescue OptionParser::InvalidOption => e
|
|
187
|
-
puts
|
|
188
|
-
puts
|
|
214
|
+
puts(e)
|
|
215
|
+
puts(global)
|
|
189
216
|
exit(1)
|
|
190
217
|
end
|
|
191
218
|
|
|
192
219
|
if options.empty?
|
|
193
|
-
puts
|
|
220
|
+
puts(global)
|
|
194
221
|
exit(1)
|
|
195
222
|
end
|
|
196
223
|
|
|
@@ -202,19 +229,19 @@ module HledgerForecast
|
|
|
202
229
|
|
|
203
230
|
global = OptionParser.new do |opts|
|
|
204
231
|
opts.banner = "Usage: hledger-forecast compare [path/to/file1.csv] [path/to/file2.csv]"
|
|
205
|
-
opts.separator
|
|
232
|
+
opts.separator("")
|
|
206
233
|
end
|
|
207
234
|
|
|
208
235
|
begin
|
|
209
236
|
global.parse!(args)
|
|
210
237
|
rescue OptionParser::InvalidOption => e
|
|
211
|
-
puts
|
|
212
|
-
puts
|
|
238
|
+
puts(e)
|
|
239
|
+
puts(global)
|
|
213
240
|
exit(1)
|
|
214
241
|
end
|
|
215
242
|
|
|
216
243
|
if args[0].nil? || args[1].nil?
|
|
217
|
-
puts
|
|
244
|
+
puts(global)
|
|
218
245
|
exit(1)
|
|
219
246
|
end
|
|
220
247
|
|
|
@@ -230,25 +257,25 @@ module HledgerForecast
|
|
|
230
257
|
begin
|
|
231
258
|
transactions = Generator.generate(forecast, options)
|
|
232
259
|
rescue StandardError => e
|
|
233
|
-
puts
|
|
260
|
+
puts("An error occurred while generating transactions: #{e.message}")
|
|
234
261
|
exit(1)
|
|
235
262
|
end
|
|
236
263
|
|
|
237
264
|
output_file = options[:output_file]
|
|
238
265
|
|
|
239
266
|
if File.exist?(output_file) && !options[:force]
|
|
240
|
-
print
|
|
267
|
+
print("\nFile '#{output_file}' already exists. Overwrite? (y/n): ")
|
|
241
268
|
overwrite = gets.chomp.downcase
|
|
242
269
|
|
|
243
|
-
if overwrite ==
|
|
270
|
+
if overwrite == "y"
|
|
244
271
|
File.write(output_file, transactions)
|
|
245
|
-
puts
|
|
272
|
+
puts("\nSuccess: ".bold.green + "File '#{output_file}' has been overwritten.")
|
|
246
273
|
else
|
|
247
|
-
puts
|
|
274
|
+
puts("\nInfo: ".bold.blue + "Operation aborted. File '#{output_file}' was not overwritten.")
|
|
248
275
|
end
|
|
249
276
|
else
|
|
250
277
|
File.write(output_file, transactions)
|
|
251
|
-
puts
|
|
278
|
+
puts("\nSuccess: ".bold.green + "File '#{output_file}' has been created")
|
|
252
279
|
end
|
|
253
280
|
end
|
|
254
281
|
|
|
@@ -258,15 +285,15 @@ module HledgerForecast
|
|
|
258
285
|
|
|
259
286
|
summarizer = Summarizer.summarize(config, options)
|
|
260
287
|
|
|
261
|
-
puts
|
|
288
|
+
puts(SummarizerFormatter.format(summarizer[:output], summarizer[:settings]))
|
|
262
289
|
end
|
|
263
290
|
|
|
264
291
|
def self.compare(options)
|
|
265
292
|
if !File.exist?(options[:file1]) || !File.exist?(options[:file2])
|
|
266
|
-
return puts
|
|
293
|
+
return puts("\nError: ".bold.red + "One or more of the files could not be found to compare")
|
|
267
294
|
end
|
|
268
295
|
|
|
269
|
-
puts
|
|
296
|
+
puts(Comparator.compare(options[:file1], options[:file2]))
|
|
270
297
|
end
|
|
271
298
|
end
|
|
272
299
|
end
|
|
@@ -20,17 +20,17 @@ module HledgerForecast
|
|
|
20
20
|
csv2 = CSV.read(file2)
|
|
21
21
|
|
|
22
22
|
unless csv1.length == csv2.length && csv1[0].length == csv2[0].length
|
|
23
|
-
return puts
|
|
23
|
+
return puts("\nError: ".bold.red + "The files have different formats and cannot be compared")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
@table.add_row
|
|
26
|
+
@table.add_row(csv2[0].map(&:bold))
|
|
27
27
|
@table.add_separator
|
|
28
28
|
|
|
29
29
|
generate_diff(csv1, csv2).drop(1).each do |row|
|
|
30
|
-
@table.add_row
|
|
30
|
+
@table.add_row([row[0].bold] + row[1..])
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
puts
|
|
33
|
+
puts(@table)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def header?(row_num)
|
|
@@ -40,7 +40,8 @@ module HledgerForecast
|
|
|
40
40
|
def generate_diff(csv1, csv2)
|
|
41
41
|
csv1.each_with_index.map do |row, i|
|
|
42
42
|
row.each_with_index.map do |cell, j|
|
|
43
|
-
|
|
43
|
+
# Checking for the first column here
|
|
44
|
+
if header?(i) || j == 0
|
|
44
45
|
csv2[i][j]
|
|
45
46
|
else
|
|
46
47
|
difference = parse_money(csv2[i][j]) - parse_money(cell)
|
|
@@ -66,17 +67,17 @@ module HledgerForecast
|
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
def parse_money(value)
|
|
69
|
-
return 0.0 if value.strip ==
|
|
70
|
+
return 0.0 if value.strip == "0"
|
|
70
71
|
|
|
71
|
-
value.gsub(/[^0-9.-]/,
|
|
72
|
+
value.gsub(/[^0-9.-]/, "").to_f
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
def format_difference(amount, currency)
|
|
75
76
|
formatted_amount = if currency.nil?
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
format("%.2f", amount)
|
|
78
|
+
else
|
|
79
|
+
Formatter.format_money(amount, Settings.parse([], {currency: currency}))
|
|
80
|
+
end
|
|
80
81
|
|
|
81
82
|
return formatted_amount if amount == 0
|
|
82
83
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module HledgerForecast
|
|
2
|
+
class Forecast
|
|
3
|
+
attr_reader :transactions, :settings
|
|
4
|
+
|
|
5
|
+
def self.parse(csv_string, cli_options = nil)
|
|
6
|
+
rows = CSV.parse(
|
|
7
|
+
csv_string,
|
|
8
|
+
headers: true,
|
|
9
|
+
header_converters: -> (h) { h.to_s.tr("-", "_").to_sym },
|
|
10
|
+
converters: :numeric
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
settings = Settings.parse(rows.select { |r| r[:type] == "settings" }, cli_options)
|
|
14
|
+
transactions = rows.reject { |r| r[:type] == "settings" }.map { |r| Transaction.from_row(r) }
|
|
15
|
+
|
|
16
|
+
new(transactions, settings, rows.headers.include?(:tag))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def has_tags_column? = @has_tags_column
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def initialize(transactions, settings, has_tags_column)
|
|
24
|
+
@transactions = transactions
|
|
25
|
+
@settings = settings
|
|
26
|
+
@has_tags_column = has_tags_column
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
# Formats various items used throughout the application
|
|
3
2
|
class Formatter
|
|
4
3
|
def self.format_money(amount, settings)
|
|
5
|
-
Money.from_cents(amount.to_f * 100,
|
|
6
|
-
symbol: settings
|
|
7
|
-
sign_before_symbol: settings
|
|
8
|
-
thousands_separator: settings
|
|
4
|
+
Money.from_cents(amount.to_f * 100, settings.currency).format(
|
|
5
|
+
symbol: settings.show_symbol,
|
|
6
|
+
sign_before_symbol: settings.sign_before_symbol,
|
|
7
|
+
thousands_separator: resolve_separator(settings.thousands_separator)
|
|
9
8
|
)
|
|
10
9
|
end
|
|
11
10
|
|
|
12
|
-
def self.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
output.gsub(/\n{2,}/, "\n\n")
|
|
11
|
+
private_class_method def self.resolve_separator(value)
|
|
12
|
+
case value
|
|
13
|
+
when "false", false, nil
|
|
14
|
+
nil
|
|
15
|
+
when "true", true
|
|
16
|
+
","
|
|
17
|
+
else
|
|
18
|
+
value
|
|
19
|
+
end
|
|
22
20
|
end
|
|
23
21
|
end
|
|
24
22
|
end
|
|
@@ -1,83 +1,43 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
# Generate forecasts for hledger from a yaml config file
|
|
3
2
|
class Generator
|
|
4
|
-
def self.generate(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def generate(config, cli_options = nil)
|
|
9
|
-
forecast = CSV.parse(config, headers: true)
|
|
10
|
-
@settings = Settings.config(forecast, cli_options)
|
|
11
|
-
|
|
12
|
-
processed = []
|
|
13
|
-
forecast.each do |row|
|
|
14
|
-
next if row['type'] == "settings"
|
|
3
|
+
def self.generate(csv_string, cli_options = nil)
|
|
4
|
+
forecast = Forecast.parse(csv_string, cli_options)
|
|
5
|
+
transactions = forecast.transactions
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
if cli_options&.dig(:tags)
|
|
8
|
+
raise "The --tags option requires a 'tag' column in the forecast CSV" unless forecast.has_tags_column?
|
|
9
|
+
transactions = transactions.select { |t| t.matches_tags?(cli_options[:tags]) }
|
|
17
10
|
end
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
processed = processed.group_by do |row|
|
|
21
|
-
[row[:type], row[:frequency], row[:from], row[:to], row[:account], row[:track]]
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
processed = processed.map do |(type, frequency, from, to, account, track), transactions|
|
|
25
|
-
{
|
|
26
|
-
type: type,
|
|
27
|
-
frequency: frequency,
|
|
28
|
-
from: from,
|
|
29
|
-
to: to,
|
|
30
|
-
account: account,
|
|
31
|
-
track: track || false,
|
|
32
|
-
transactions: transactions
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
Formatter.output_to_ledger(
|
|
38
|
-
Transactions::Default.generate(processed, @settings),
|
|
39
|
-
Transactions::Trackers.generate(processed, @settings)
|
|
40
|
-
)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def process_forecast(row)
|
|
46
|
-
row['amount'] = Utilities.convert_amount(row['amount'])
|
|
47
|
-
|
|
48
|
-
{
|
|
49
|
-
type: row['type'],
|
|
50
|
-
frequency: row['frequency'] || nil,
|
|
51
|
-
account: row['account'],
|
|
52
|
-
from: Date.parse(row['from']),
|
|
53
|
-
to: row['to'] ? Calculator.new.evaluate_date(Date.parse(row['from']), row['to']) : nil,
|
|
54
|
-
description: row['description'],
|
|
55
|
-
category: row['category'],
|
|
56
|
-
amount: Formatter.format_money(Calculator.new.evaluate(row['amount']), @settings),
|
|
57
|
-
track: Transactions::Trackers.track?(row, @settings) ? true : false
|
|
58
|
-
}
|
|
12
|
+
Transactions::Default.render(build_groups(transactions, forecast.settings), forecast.settings)
|
|
59
13
|
end
|
|
60
14
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
15
|
+
private_class_method def self.build_groups(transactions, settings)
|
|
16
|
+
if settings.verbose?
|
|
17
|
+
transactions.map do |t|
|
|
18
|
+
TransactionGroup.new(
|
|
19
|
+
type: t.type,
|
|
20
|
+
frequency: t.frequency,
|
|
21
|
+
account: t.account,
|
|
22
|
+
from: t.from,
|
|
23
|
+
to: t.to,
|
|
24
|
+
transactions: [t]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
transactions
|
|
29
|
+
.group_by { |t| [t.type, t.frequency, t.from, t.to, t.account] }
|
|
30
|
+
.map do |(type, frequency, from, to, account), txns|
|
|
31
|
+
TransactionGroup.new(
|
|
32
|
+
type: type,
|
|
33
|
+
frequency: frequency,
|
|
34
|
+
account: account,
|
|
35
|
+
from: from,
|
|
36
|
+
to: to,
|
|
37
|
+
transactions: txns
|
|
38
|
+
)
|
|
39
|
+
end
|
|
78
40
|
end
|
|
79
|
-
|
|
80
|
-
transformed_data
|
|
81
41
|
end
|
|
82
42
|
end
|
|
83
43
|
end
|