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.
@@ -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