hledger-forecast 0.4.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,282 +1,65 @@
1
1
  module HledgerForecast
2
- # Generates periodic transactions from a YAML file
2
+ # Generate forecasts for hledger from a yaml config file
3
3
  class Generator
4
- class << self
5
- attr_accessor :calculator, :options, :modified, :tracked
4
+ def self.generate(config, cli_options = nil)
5
+ new.generate(config, cli_options)
6
6
  end
7
7
 
8
- self.calculator = {}
9
- self.options = {}
10
- self.modified = {}
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
- def self.set_options(forecast_data)
14
- @options[:max_amount] = get_max_field_size(forecast_data, 'amount') + 1 # +1 for the negatives
15
- @options[:max_category] = get_max_field_size(forecast_data, 'category')
12
+ output = {}
13
+ forecast.each do |period, blocks|
14
+ next if %w[settings].include?(period)
16
15
 
17
- @options[:currency] = Money::Currency.new(forecast_data.fetch('settings', {}).fetch('currency', 'USD'))
18
- @options[:show_symbol] = forecast_data.fetch('settings', {}).fetch('show_symbol', true)
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
- output
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
- def self.output_tracked_transaction(transactions)
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
- output
184
- end
30
+ def process_block(period, block)
31
+ output = []
185
32
 
186
- def self.extract_descriptions(transactions, from)
187
- descriptions = []
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
- transactions.each do |transaction|
190
- next if track_transaction?(transaction, from)
42
+ output = process_transactions(block, output)
191
43
 
192
- description = transaction['description']
193
- descriptions << description
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 self.modified_transaction(from, to, account, transaction)
200
- return unless transaction['modifiers']
201
-
202
- transaction['modifiers'].each do |modifier|
203
- description = transaction['description']
204
- description += ' - ' + modifier['description'] unless modifier['description'].empty?
205
-
206
- @modified[@modified.length] = {
207
- 'account' => account,
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
- def self.track_transaction?(transaction, from)
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