hledger-forecast 0.4.0 → 1.1.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/ci.yml +1 -1
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -0
- data/README.md +111 -41
- data/example.journal +33 -22
- data/example.yml +40 -26
- data/hledger-forecast.gemspec +1 -0
- data/lib/hledger_forecast/calculator.rb +21 -0
- data/lib/hledger_forecast/cli.rb +27 -2
- data/lib/hledger_forecast/formatter.rb +24 -0
- data/lib/hledger_forecast/generator.rb +41 -258
- data/lib/hledger_forecast/settings.rb +41 -0
- data/lib/hledger_forecast/summarizer.rb +106 -0
- data/lib/hledger_forecast/summarizer_formatter.rb +115 -0
- data/lib/hledger_forecast/transactions/default.rb +88 -0
- data/lib/hledger_forecast/transactions/modifiers.rb +90 -0
- data/lib/hledger_forecast/transactions/trackers.rb +87 -0
- data/lib/hledger_forecast/version.rb +1 -1
- data/lib/hledger_forecast.rb +12 -4
- data/spec/custom_spec.rb +31 -4
- data/spec/modifier_spec.rb +21 -6
- data/spec/monthly_end_date_spec.rb +18 -33
- data/spec/monthly_end_date_transaction_spec.rb +44 -7
- data/spec/monthly_spec.rb +7 -7
- data/spec/summarizer_spec.rb +86 -0
- data/spec/track_spec.rb +5 -67
- metadata +15 -7
- data/lib/hledger_forecast/summarize.rb +0 -134
- data/lib/hledger_forecast/tracker.rb +0 -37
@@ -1,282 +1,65 @@
|
|
1
1
|
module HledgerForecast
|
2
|
-
#
|
2
|
+
# Generate forecasts for hledger from a yaml config file
|
3
3
|
class Generator
|
4
|
-
|
5
|
-
|
4
|
+
def self.generate(config, cli_options = nil)
|
5
|
+
new.generate(config, cli_options)
|
6
6
|
end
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
self.tracked = {}
|
8
|
+
def generate(config, cli_options = nil)
|
9
|
+
forecast = YAML.safe_load(config)
|
10
|
+
@settings = Settings.config(forecast, cli_options)
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
|
12
|
+
output = {}
|
13
|
+
forecast.each do |period, blocks|
|
14
|
+
next if %w[settings].include?(period)
|
16
15
|
|
17
|
-
|
18
|
-
|
19
|
-
# @options[:sign_before_symbol] = forecast_data.fetch('settings', {}).fetch('sign_before_symbol', false)
|
20
|
-
@options[:thousands_separator] = forecast_data.fetch('settings', {}).fetch('thousands_separator', true)
|
21
|
-
end
|
22
|
-
|
23
|
-
def self.generate(yaml_file, options = nil)
|
24
|
-
forecast_data = YAML.safe_load(yaml_file)
|
25
|
-
|
26
|
-
set_options(forecast_data)
|
27
|
-
|
28
|
-
@calculator = Dentaku::Calculator.new
|
29
|
-
|
30
|
-
output = ""
|
31
|
-
|
32
|
-
# Generate regular transactions
|
33
|
-
forecast_data.each do |period, forecasts|
|
34
|
-
if period == 'custom'
|
35
|
-
output += custom_transaction(forecasts)
|
36
|
-
else
|
37
|
-
frequency = convert_period_to_frequency(period)
|
38
|
-
next unless frequency
|
39
|
-
|
40
|
-
forecasts.each do |forecast|
|
41
|
-
account = forecast['account']
|
42
|
-
from = Date.parse(forecast['from'])
|
43
|
-
to = forecast['to'] ? Date.parse(forecast['to']) : nil
|
44
|
-
transactions = forecast['transactions']
|
45
|
-
|
46
|
-
output += regular_transaction(frequency, from, to, transactions, account)
|
47
|
-
output += ending_transaction(frequency, from, transactions, account)
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
# Generate tracked transactions
|
53
|
-
if options && !options[:no_track] && !@tracked.empty?
|
54
|
-
if options[:transaction_file]
|
55
|
-
output += output_tracked_transaction(Tracker.track(@tracked,
|
56
|
-
options[:transaction_file]))
|
57
|
-
else
|
58
|
-
puts "\nWarning: ".yellow.bold + "You need to specify a transaction file with the `--t` flag for smart transactions to work\n"
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
output += output_modified_transaction(@modified) unless @modified.empty?
|
63
|
-
|
64
|
-
output
|
65
|
-
end
|
66
|
-
|
67
|
-
def self.regular_transaction(frequency, from, to, transactions, account)
|
68
|
-
transactions = transactions.select { |transaction| transaction['to'].nil? }
|
69
|
-
return "" if transactions.empty?
|
70
|
-
|
71
|
-
output = ""
|
72
|
-
|
73
|
-
transactions.each do |transaction|
|
74
|
-
if track_transaction?(transaction, from)
|
75
|
-
track_transaction(from, to, account, transaction)
|
76
|
-
next
|
77
|
-
end
|
78
|
-
|
79
|
-
modified_transaction(from, to, account, transaction)
|
80
|
-
|
81
|
-
output += output_transaction(transaction['category'], format_amount(calculate_amount(transaction['amount'])),
|
82
|
-
transaction['description'])
|
83
|
-
end
|
84
|
-
|
85
|
-
return "" unless output != ""
|
86
|
-
|
87
|
-
output = if to
|
88
|
-
"#{frequency} #{from} to #{to} * #{extract_descriptions(transactions,
|
89
|
-
from)}\n" << output
|
90
|
-
else
|
91
|
-
"#{frequency} #{from} * #{extract_descriptions(transactions, from)}\n" << output
|
92
|
-
end
|
93
|
-
|
94
|
-
output += " #{account}\n\n"
|
95
|
-
output
|
96
|
-
end
|
97
|
-
|
98
|
-
def self.ending_transaction(frequency, from, transactions, account)
|
99
|
-
output = ""
|
100
|
-
|
101
|
-
transactions.each do |transaction|
|
102
|
-
to = transaction['to'] ? Date.parse(transaction['to']) : nil
|
103
|
-
next unless to
|
104
|
-
|
105
|
-
if track_transaction?(transaction, from)
|
106
|
-
track_transaction(from, to, account, transaction)
|
107
|
-
next
|
108
|
-
end
|
109
|
-
|
110
|
-
modified_transaction(from, to, account, transaction)
|
111
|
-
|
112
|
-
output += "#{frequency} #{from} to #{to} * #{transaction['description']}\n"
|
113
|
-
output += output_transaction(transaction['category'], format_amount(calculate_amount(transaction['amount'])),
|
114
|
-
transaction['description'])
|
115
|
-
output += " #{account}\n\n"
|
116
|
-
end
|
117
|
-
|
118
|
-
output
|
119
|
-
end
|
120
|
-
|
121
|
-
def self.custom_transaction(forecasts)
|
122
|
-
output = ""
|
123
|
-
|
124
|
-
forecasts.each do |forecast|
|
125
|
-
account = forecast['account']
|
126
|
-
from = Date.parse(forecast['from'])
|
127
|
-
to = forecast['to'] ? Date.parse(forecast['to']) : nil
|
128
|
-
frequency = forecast['frequency']
|
129
|
-
transactions = forecast['transactions']
|
130
|
-
|
131
|
-
output += "~ #{frequency} from #{from} * #{extract_descriptions(transactions, from)}\n"
|
132
|
-
|
133
|
-
transactions.each do |transaction|
|
134
|
-
to = transaction['to'] ? Date.parse(transaction['to']) : to
|
135
|
-
|
136
|
-
if track_transaction?(transaction, from)
|
137
|
-
track_transaction(from, to, account, transaction)
|
138
|
-
next
|
139
|
-
end
|
140
|
-
|
141
|
-
modified_transaction(from, to, account, transaction)
|
142
|
-
|
143
|
-
output += output_transaction(transaction['category'], format_amount(calculate_amount(transaction['amount'])),
|
144
|
-
transaction['description'])
|
16
|
+
blocks.each do |block|
|
17
|
+
output[output.length] = process_block(period, block)
|
145
18
|
end
|
146
|
-
|
147
|
-
output += " #{account}\n\n"
|
148
|
-
end
|
149
|
-
|
150
|
-
output
|
151
|
-
end
|
152
|
-
|
153
|
-
def self.output_transaction(category, amount, description)
|
154
|
-
" #{category.ljust(@options[:max_category])} #{amount.ljust(@options[:max_amount])}; #{description}\n"
|
155
|
-
end
|
156
|
-
|
157
|
-
def self.output_modified_transaction(transactions)
|
158
|
-
output = ""
|
159
|
-
|
160
|
-
transactions.each do |_key, transaction|
|
161
|
-
date = "date:#{transaction['from']}"
|
162
|
-
date += "..#{transaction['to']}" if transaction['to']
|
163
|
-
|
164
|
-
output += "= #{transaction['category']} #{date}\n"
|
165
|
-
output += " #{transaction['category'].ljust(@options[:max_category])} *#{transaction['amount'].to_s.ljust(@options[:max_amount] - 1)}; #{transaction['description']}\n"
|
166
|
-
output += " #{transaction['account'].ljust(@options[:max_category])} *#{transaction['amount'] * -1}\n\n"
|
167
19
|
end
|
168
20
|
|
169
|
-
|
21
|
+
Formatter.output_to_ledger(
|
22
|
+
Transactions::Default.generate(output, @settings),
|
23
|
+
Transactions::Trackers.generate(output, @settings),
|
24
|
+
Transactions::Modifiers.generate(output, @settings)
|
25
|
+
)
|
170
26
|
end
|
171
27
|
|
172
|
-
|
173
|
-
output = ""
|
174
|
-
|
175
|
-
transactions.each do |_key, transaction|
|
176
|
-
next if transaction['found']
|
177
|
-
|
178
|
-
output += "~ #{transaction['from']} * [TRACKED] #{transaction['transaction']['description']}\n"
|
179
|
-
output += " #{transaction['transaction']['category'].ljust(@options[:max_category])} #{transaction['transaction']['amount'].ljust(@options[:max_amount])}; #{transaction['transaction']['description']}\n"
|
180
|
-
output += " #{transaction['account']}\n\n"
|
181
|
-
end
|
28
|
+
private
|
182
29
|
|
183
|
-
|
184
|
-
|
30
|
+
def process_block(period, block)
|
31
|
+
output = []
|
185
32
|
|
186
|
-
|
187
|
-
|
33
|
+
output << {
|
34
|
+
account: block['account'],
|
35
|
+
from: Date.parse(block['from']),
|
36
|
+
to: block['to'] ? Date.parse(block['to']) : nil,
|
37
|
+
type: period,
|
38
|
+
frequency: block['frequency'],
|
39
|
+
transactions: []
|
40
|
+
}
|
188
41
|
|
189
|
-
|
190
|
-
next if track_transaction?(transaction, from)
|
42
|
+
output = process_transactions(block, output)
|
191
43
|
|
192
|
-
|
193
|
-
|
44
|
+
output.map do |item|
|
45
|
+
transactions = item[:transactions].group_by { |t| t[:to] }
|
46
|
+
item.merge(transactions: transactions)
|
194
47
|
end
|
195
|
-
|
196
|
-
descriptions.join(', ')
|
197
48
|
end
|
198
49
|
|
199
|
-
def
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
'amount' => modifier['amount'],
|
209
|
-
'category' => transaction['category'],
|
210
|
-
'description' => description,
|
211
|
-
'from' => modifier['from'] ? Date.parse(modifier['from']) : (from || nil),
|
212
|
-
'to' => modifier['to'] ? Date.parse(modifier['to']) : (to || nil)
|
50
|
+
def process_transactions(block, output)
|
51
|
+
block['transactions'].each do |t|
|
52
|
+
output.last[:transactions] << {
|
53
|
+
category: t['category'],
|
54
|
+
amount: Formatter.format_money(Calculator.new.evaluate(t['amount']), @settings),
|
55
|
+
description: t['description'],
|
56
|
+
to: t['to'] ? Calculator.new.evaluate_date(Date.parse(block['from']), t['to']) : nil,
|
57
|
+
modifiers: t['modifiers'] ? Transactions::Modifiers.get_modifiers(t, block) : [],
|
58
|
+
track: Transactions::Trackers.track?(t, block, @settings) ? true : false
|
213
59
|
}
|
214
60
|
end
|
215
|
-
end
|
216
61
|
|
217
|
-
|
218
|
-
transaction['track'] && from <= Date.today
|
219
|
-
end
|
220
|
-
|
221
|
-
def self.track_transaction(from, to, account, transaction)
|
222
|
-
amount = calculate_amount(transaction['amount'])
|
223
|
-
transaction['amount'] = format_amount(amount)
|
224
|
-
transaction['inverse_amount'] = format_amount(amount * -1)
|
225
|
-
|
226
|
-
@tracked[@tracked.length] = {
|
227
|
-
'account' => account,
|
228
|
-
'from' => from,
|
229
|
-
'to' => to,
|
230
|
-
'transaction' => transaction
|
231
|
-
}
|
232
|
-
end
|
233
|
-
|
234
|
-
def self.convert_period_to_frequency(period)
|
235
|
-
map = {
|
236
|
-
'once' => '~',
|
237
|
-
'monthly' => '~ monthly from',
|
238
|
-
'quarterly' => '~ every 3 months from',
|
239
|
-
'half-yearly' => '~ every 6 months from',
|
240
|
-
'yearly' => '~ yearly from'
|
241
|
-
}
|
242
|
-
|
243
|
-
map[period]
|
244
|
-
end
|
245
|
-
|
246
|
-
def self.calculate_amount(amount)
|
247
|
-
return amount unless amount.is_a?(String)
|
248
|
-
|
249
|
-
@calculator.evaluate(amount.slice(1..-1))
|
250
|
-
end
|
251
|
-
|
252
|
-
def self.format_amount(amount)
|
253
|
-
Money.from_cents(amount.to_f * 100, @options[:currency]).format(
|
254
|
-
symbol: @options[:show_symbol],
|
255
|
-
sign_before_symbol: @options[:sign_before_symbol],
|
256
|
-
thousands_separator: @options[:thousands_separator] ? ',' : nil
|
257
|
-
)
|
258
|
-
end
|
259
|
-
|
260
|
-
def self.get_max_field_size(forecast_data, field)
|
261
|
-
max_size = 0
|
262
|
-
|
263
|
-
forecast_data.each do |period, forecasts|
|
264
|
-
next if period == 'settings'
|
265
|
-
|
266
|
-
forecasts.each do |forecast|
|
267
|
-
transactions = forecast['transactions']
|
268
|
-
transactions.each do |transaction|
|
269
|
-
field_value = if transaction[field].is_a?(Integer) || transaction[field].is_a?(Float)
|
270
|
-
((transaction[field] + 3) * 100).to_s
|
271
|
-
else
|
272
|
-
transaction[field].to_s
|
273
|
-
end
|
274
|
-
max_size = [max_size, field_value.length].max
|
275
|
-
end
|
276
|
-
end
|
277
|
-
end
|
278
|
-
|
279
|
-
max_size
|
62
|
+
output
|
280
63
|
end
|
281
64
|
end
|
282
65
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module HledgerForecast
|
2
|
+
# Set the options from a user's confgi
|
3
|
+
class Settings
|
4
|
+
def self.config(forecast, cli_options)
|
5
|
+
settings = {}
|
6
|
+
|
7
|
+
settings[:max_amount] = get_max_field_size(forecast, 'amount') + 1 # +1 for the negatives
|
8
|
+
settings[:max_category] = get_max_field_size(forecast, 'category')
|
9
|
+
|
10
|
+
settings[:currency] = Money::Currency.new(forecast.fetch('settings', {}).fetch('currency', 'USD'))
|
11
|
+
settings[:show_symbol] = forecast.fetch('settings', {}).fetch('show_symbol', true)
|
12
|
+
# settings[:sign_before_symbol] = forecast.fetch('settings', {}).fetch('sign_before_symbol', false)
|
13
|
+
settings[:thousands_separator] = forecast.fetch('settings', {}).fetch('thousands_separator', true)
|
14
|
+
|
15
|
+
settings.merge!(cli_options) if cli_options
|
16
|
+
|
17
|
+
settings
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.get_max_field_size(block, field)
|
21
|
+
max_size = 0
|
22
|
+
|
23
|
+
block.each do |period, items|
|
24
|
+
next if %w[settings].include?(period)
|
25
|
+
|
26
|
+
items.each do |item|
|
27
|
+
item['transactions'].each do |t|
|
28
|
+
field_value = if t[field].is_a?(Integer) || t[field].is_a?(Float)
|
29
|
+
((t[field] + 3) * 100).to_s
|
30
|
+
else
|
31
|
+
t[field].to_s
|
32
|
+
end
|
33
|
+
max_size = [max_size, field_value.length].max
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
max_size
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module HledgerForecast
|
2
|
+
# Summarise a forecast yaml file and output it to the CLI
|
3
|
+
class Summarizer
|
4
|
+
def self.summarize(config, cli_options)
|
5
|
+
new.summarize(config, cli_options)
|
6
|
+
end
|
7
|
+
|
8
|
+
def summarize(config, cli_options = nil)
|
9
|
+
@forecast = YAML.safe_load(config)
|
10
|
+
@settings = Settings.config(@forecast, cli_options)
|
11
|
+
|
12
|
+
return { output: generate(@forecast), settings: @settings }
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def generate(forecast)
|
18
|
+
output = {}
|
19
|
+
forecast.each do |period, blocks|
|
20
|
+
next if %w[settings].include?(period)
|
21
|
+
|
22
|
+
blocks.each do |block|
|
23
|
+
key = if @settings[:roll_up].nil?
|
24
|
+
period
|
25
|
+
else
|
26
|
+
output.length
|
27
|
+
end
|
28
|
+
|
29
|
+
output[key] ||= []
|
30
|
+
output[key] << process_block(period, block)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
output = filter_out(flatten_and_merge(output))
|
35
|
+
output = calculate_rolled_up_amount(output) unless @settings[:roll_up].nil?
|
36
|
+
|
37
|
+
output
|
38
|
+
end
|
39
|
+
|
40
|
+
def process_block(period, block)
|
41
|
+
output = []
|
42
|
+
|
43
|
+
output << {
|
44
|
+
account: block['account'],
|
45
|
+
from: Date.parse(block['from']),
|
46
|
+
to: block['to'] ? Date.parse(block['to']) : nil,
|
47
|
+
type: period,
|
48
|
+
frequency: block['frequency'],
|
49
|
+
transactions: []
|
50
|
+
}
|
51
|
+
|
52
|
+
process_transactions(period, block, output)
|
53
|
+
end
|
54
|
+
|
55
|
+
def process_transactions(period, block, output)
|
56
|
+
block['transactions'].each do |t|
|
57
|
+
amount = Calculator.new.evaluate(t['amount'])
|
58
|
+
|
59
|
+
output.last[:transactions] << {
|
60
|
+
amount: amount,
|
61
|
+
annualised_amount: amount * (block['roll-up'] || annualise(period)),
|
62
|
+
rolled_up_amount: 0,
|
63
|
+
category: t['category'],
|
64
|
+
exclude: t['summary_exclude'],
|
65
|
+
description: t['description'],
|
66
|
+
to: t['to'] ? Calculator.new.evaluate_date(Date.parse(block['from']), t['to']) : nil
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
output
|
71
|
+
end
|
72
|
+
|
73
|
+
def annualise(period)
|
74
|
+
annualise = {
|
75
|
+
'monthly' => 12,
|
76
|
+
'quarterly' => 4,
|
77
|
+
'half-yearly' => 2,
|
78
|
+
'yearly' => 1,
|
79
|
+
'once' => 1,
|
80
|
+
'daily' => 352,
|
81
|
+
'weekly' => 52
|
82
|
+
}
|
83
|
+
|
84
|
+
annualise[period]
|
85
|
+
end
|
86
|
+
|
87
|
+
def filter_out(data)
|
88
|
+
data.reject { |item| item[:exclude] == true }
|
89
|
+
end
|
90
|
+
|
91
|
+
def flatten_and_merge(blocks)
|
92
|
+
blocks.values.flatten.flat_map do |block|
|
93
|
+
block[:transactions].map do |transaction|
|
94
|
+
block.slice(:account, :from, :to, :type, :frequency).merge(transaction)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def calculate_rolled_up_amount(data)
|
100
|
+
data.map do |item|
|
101
|
+
item[:rolled_up_amount] = item[:annualised_amount] / annualise(@settings[:roll_up])
|
102
|
+
item
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module HledgerForecast
|
2
|
+
# Output the summarised forecast to the CLI
|
3
|
+
class SummarizerFormatter
|
4
|
+
def self.format(output, settings)
|
5
|
+
new.format(output, settings)
|
6
|
+
end
|
7
|
+
|
8
|
+
def format(output, settings)
|
9
|
+
@table = Terminal::Table.new
|
10
|
+
@settings = settings
|
11
|
+
|
12
|
+
init_table
|
13
|
+
|
14
|
+
if @settings[:roll_up].nil?
|
15
|
+
add_rows_to_table(output)
|
16
|
+
add_total_row_to_table(output, :amount)
|
17
|
+
else
|
18
|
+
add_rolled_up_rows_to_table(output)
|
19
|
+
add_total_row_to_table(output, :rolled_up_amount)
|
20
|
+
end
|
21
|
+
|
22
|
+
@table
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def init_table
|
28
|
+
title = 'FORECAST SUMMARY'
|
29
|
+
title += " (#{@settings[:roll_up].upcase} ROLL UP)" if @settings[:roll_up]
|
30
|
+
|
31
|
+
@table.add_row([{ value: title.bold, colspan: 3, alignment: :center }])
|
32
|
+
@table.add_separator
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_rows_to_table(data)
|
36
|
+
data = data.group_by { |item| item[:type] }
|
37
|
+
|
38
|
+
data = sort(data)
|
39
|
+
|
40
|
+
data.each_with_index do |(type, items), index|
|
41
|
+
@table.add_row([{ value: type.capitalize.bold, colspan: 3, alignment: :center }])
|
42
|
+
total = 0
|
43
|
+
items.each do |item|
|
44
|
+
total += item[:amount]
|
45
|
+
@table.add_row [{ value: item[:category], colspan: 2, alignment: :left },
|
46
|
+
{ value: format_amount(item[:amount]), alignment: :right }]
|
47
|
+
end
|
48
|
+
|
49
|
+
@table.add_row [{ value: "TOTAL".bold, colspan: 2, alignment: :left },
|
50
|
+
{ value: format_amount(total).bold, alignment: :right }]
|
51
|
+
|
52
|
+
@table.add_separator if index != data.size - 1
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def sort(data)
|
57
|
+
data.each do |type, items|
|
58
|
+
data[type] = items.sort_by do |item|
|
59
|
+
value = item[:amount]
|
60
|
+
[value >= 0 ? 1 : 0, value >= 0 ? -value : value]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def add_rolled_up_rows_to_table(data)
|
66
|
+
sum_hash = Hash.new { |h, k| h[k] = { sum: 0, descriptions: [] } }
|
67
|
+
|
68
|
+
data.each do |item|
|
69
|
+
sum_hash[item[:category]][:sum] += item[:rolled_up_amount]
|
70
|
+
sum_hash[item[:category]][:descriptions] << item[:description]
|
71
|
+
end
|
72
|
+
|
73
|
+
# Convert arrays of descriptions to single strings
|
74
|
+
sum_hash.each do |_category, values|
|
75
|
+
values[:descriptions] = values[:descriptions].join(", ")
|
76
|
+
end
|
77
|
+
|
78
|
+
# Sort the array
|
79
|
+
sorted_sums = sort_roll_up(sum_hash, :sum)
|
80
|
+
|
81
|
+
sorted_sums.each do |hash|
|
82
|
+
@table.add_row [{ value: hash[:category], colspan: 2, alignment: :left },
|
83
|
+
{ value: format_amount(hash[:sum]), alignment: :right }]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def sort_roll_up(data, sort_by)
|
88
|
+
# Convert the hash to an array of hashes
|
89
|
+
array = data.map do |category, values|
|
90
|
+
{ category: category, sum: values[sort_by], descriptions: values[:descriptions] }
|
91
|
+
end
|
92
|
+
|
93
|
+
# Sort the array
|
94
|
+
array.sort_by do |hash|
|
95
|
+
value = hash[:sum]
|
96
|
+
[value >= 0 ? 1 : 0, value >= 0 ? -value : value]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def add_total_row_to_table(data, row_to_sum)
|
101
|
+
total = data.reduce(0) do |sum, item|
|
102
|
+
sum + item[row_to_sum]
|
103
|
+
end
|
104
|
+
|
105
|
+
@table.add_separator
|
106
|
+
@table.add_row [{ value: "TOTAL".bold, colspan: 2, alignment: :left },
|
107
|
+
{ value: format_amount(total).bold, alignment: :right }]
|
108
|
+
end
|
109
|
+
|
110
|
+
def format_amount(amount)
|
111
|
+
formatted_amount = Formatter.format_money(amount, @settings)
|
112
|
+
amount.to_f < 0 ? formatted_amount.green : formatted_amount.red
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module HledgerForecast
|
2
|
+
module Transactions
|
3
|
+
# Generate default hledger transactions
|
4
|
+
# Example output:
|
5
|
+
# ~ monthly from 2023-05-1 * Food expenses
|
6
|
+
# Expenses:Groceries $250.00 ; Food expenses
|
7
|
+
# Assets:Checking
|
8
|
+
class Default
|
9
|
+
def self.generate(data, options)
|
10
|
+
new(data, options).generate
|
11
|
+
end
|
12
|
+
|
13
|
+
def generate
|
14
|
+
data.each_value do |blocks|
|
15
|
+
blocks.each do |block|
|
16
|
+
process_block(block)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
output
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :data, :options, :output
|
26
|
+
|
27
|
+
def initialize(data, options)
|
28
|
+
@data = data
|
29
|
+
@options = options
|
30
|
+
@output = []
|
31
|
+
end
|
32
|
+
|
33
|
+
def process_block(block)
|
34
|
+
block[:transactions].each do |to, transactions|
|
35
|
+
to = get_header(block[:to], to)
|
36
|
+
block[:descriptions] = get_descriptions(transactions)
|
37
|
+
frequency = get_periodic_rules(block[:type], block[:frequency])
|
38
|
+
|
39
|
+
header = "#{frequency} #{block[:from]}#{to} * #{block[:descriptions]}\n"
|
40
|
+
footer = " #{block[:account]}\n\n"
|
41
|
+
|
42
|
+
output << { header: header, transactions: write_transactions(transactions), footer: footer }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def get_header(block, transaction)
|
47
|
+
return " to #{transaction}" if transaction
|
48
|
+
return " to #{block}" if block
|
49
|
+
|
50
|
+
return nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_descriptions(transactions)
|
54
|
+
transactions.map do |t|
|
55
|
+
# Skip transactions that have been marked as tracked
|
56
|
+
next if t[:track]
|
57
|
+
|
58
|
+
t[:description]
|
59
|
+
end.compact.join(', ')
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_periodic_rules(type, frequency)
|
63
|
+
map = {
|
64
|
+
'once' => '~',
|
65
|
+
'monthly' => '~ monthly from',
|
66
|
+
'quarterly' => '~ every 3 months from',
|
67
|
+
'half-yearly' => '~ every 6 months from',
|
68
|
+
'yearly' => '~ yearly from',
|
69
|
+
'custom' => "~ #{frequency} from"
|
70
|
+
}
|
71
|
+
|
72
|
+
map[type]
|
73
|
+
end
|
74
|
+
|
75
|
+
def write_transactions(transactions)
|
76
|
+
transactions.map do |t|
|
77
|
+
# Skip transactions that have been marked as tracked
|
78
|
+
next if t[:track]
|
79
|
+
|
80
|
+
t[:amount] = t[:amount].to_s.ljust(options[:max_amount])
|
81
|
+
t[:category] = t[:category].ljust(options[:max_category])
|
82
|
+
|
83
|
+
" #{t[:category]} #{t[:amount]}; #{t[:description]}\n"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|