hledger-forecast 0.4.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,37 +1,54 @@
1
1
  module HledgerForecast
2
2
  # Summarise a forecast YAML file and output it to the CLI
3
- class Summarize
4
- @table = nil
5
- @generator = nil
6
-
7
- def self.init_table
8
- table = Terminal::Table.new
3
+ # TODO: Rename this to Summarizer and the main method becomes summarize
4
+ class Summarizer
5
+ def self.summarize(config, cli_options)
6
+ new.summarize(config, cli_options)
7
+ end
9
8
 
10
- table.add_row([{ value: 'FORECAST SUMMARY'.bold, colspan: 3, alignment: :center }])
11
- table.add_separator
9
+ def summarize(config, cli_options = nil)
10
+ @forecast = YAML.safe_load(config)
11
+ @settings = Settings.config(@forecast, cli_options)
12
+ @table = Terminal::Table.new
12
13
 
13
- @table = table
14
+ generate(@forecast)
14
15
  end
15
16
 
16
- def self.init_generator(forecast_data)
17
- generator = HledgerForecast::Generator
18
- generator.set_options(forecast_data)
17
+ private
18
+
19
+ def generate(forecast)
20
+ init_table
21
+
22
+ category_totals = {}
23
+ %w[monthly quarterly half-yearly yearly once custom].each do |period|
24
+ category_totals[period] = sum_transactions(forecast, period)
25
+ end
19
26
 
20
- @generator = generator
27
+ add_categories_to_table(category_totals, forecast)
28
+
29
+ @table.add_separator
30
+ format_total("TOTAL", category_totals.values.map(&:values).flatten.sum)
31
+
32
+ @table
33
+ end
34
+
35
+ def init_table
36
+ @table.add_row([{ value: 'FORECAST SUMMARY'.bold, colspan: 3, alignment: :center }])
37
+ @table.add_separator
21
38
  end
22
39
 
23
- def self.sum_transactions(forecast_data, period)
40
+ def sum_transactions(forecast, period)
24
41
  category_total = Hash.new(0)
25
- forecast_data[period]&.each do |entry|
42
+ forecast[period]&.each do |entry|
26
43
  entry['transactions'].each do |transaction|
27
- category_total[transaction['category']] += transaction['amount']
44
+ category_total[transaction['category']] += Calculator.new.evaluate(transaction['amount'])
28
45
  end
29
46
  end
30
47
 
31
48
  category_total
32
49
  end
33
50
 
34
- def self.sum_custom_transactions(forecast_data)
51
+ def sum_custom_transactions(forecast_data)
35
52
  category_total = Hash.new(0)
36
53
  custom_periods = []
37
54
 
@@ -51,12 +68,12 @@ module HledgerForecast
51
68
  { totals: category_total, periods: custom_periods }
52
69
  end
53
70
 
54
- def self.format_amount(amount)
55
- formatted_amount = @generator.format_amount(amount)
71
+ def format_amount(amount)
72
+ formatted_amount = Formatter.format_money(amount, @settings)
56
73
  amount.to_f < 0 ? formatted_amount.green : formatted_amount.red
57
74
  end
58
75
 
59
- def self.add_rows_to_table(row_data, period_total, custom: false)
76
+ def add_rows_to_table(row_data, period_total, custom: false)
60
77
  if custom
61
78
  row_data[:periods].each do |period|
62
79
  @table.add_row [{ value: period[:category], alignment: :left },
@@ -77,7 +94,7 @@ module HledgerForecast
77
94
  period_total
78
95
  end
79
96
 
80
- def self.add_categories_to_table(categories, forecast_data)
97
+ def add_categories_to_table(categories, forecast_data)
81
98
  first_period = true
82
99
  categories.each do |period, total|
83
100
  category_total = total.reject { |_, amount| amount == 0 }
@@ -100,35 +117,16 @@ module HledgerForecast
100
117
  end
101
118
  end
102
119
 
103
- def self.sort_transactions(category_total)
120
+ def sort_transactions(category_total)
104
121
  negatives = category_total.select { |_, amount| amount < 0 }.sort_by { |_, amount| amount }
105
122
  positives = category_total.select { |_, amount| amount > 0 }.sort_by { |_, amount| -amount }
106
123
 
107
124
  negatives.concat(positives).to_h
108
125
  end
109
126
 
110
- def self.format_total(text, total)
127
+ def format_total(text, total)
111
128
  @table.add_row [{ value: text.bold, colspan: 2, alignment: :left },
112
- { value: format_amount(total).bold, alignment: :right }]
113
- end
114
-
115
- def self.generate(forecast)
116
- forecast_data = YAML.safe_load(forecast)
117
-
118
- init_table
119
- init_generator(forecast_data)
120
-
121
- category_totals = {}
122
- %w[monthly quarterly half-yearly yearly once custom].each do |period|
123
- category_totals[period] = sum_transactions(forecast_data, period)
124
- end
125
-
126
- add_categories_to_table(category_totals, forecast_data)
127
-
128
- @table.add_separator
129
- format_total("TOTAL", category_totals.values.map(&:values).flatten.sum)
130
-
131
- puts @table
129
+ { value: format_amount(total).bold, alignment: :right }]
132
130
  end
133
131
  end
134
132
  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
@@ -0,0 +1,90 @@
1
+ module HledgerForecast
2
+ module Transactions
3
+ # Generate auto-posting hledger transactions
4
+ # Example output:
5
+ # = Expenses:Groceries date:2024-01-01..2025-12-31
6
+ # Expenses:Groceries *0.1 ; Groceries
7
+ # Assets:Checking *-0.1
8
+ class Modifiers
9
+ def self.generate(data, options)
10
+ new(data, options).generate
11
+ end
12
+
13
+ def generate
14
+ return nil unless modifiers?
15
+
16
+ process_modifier
17
+
18
+ output
19
+ end
20
+
21
+ def self.get_modifiers(transaction, block)
22
+ modifiers = []
23
+
24
+ transaction['modifiers'].each do |modifier|
25
+ description = transaction['description']
26
+ description += " - #{modifier['description']}" unless modifier['description'].empty?
27
+
28
+ modifiers << {
29
+ account: block['account'],
30
+ amount: modifier['amount'],
31
+ category: transaction['category'],
32
+ description: description,
33
+ from: Date.parse(modifier['from'] || block['from']),
34
+ to: modifier['to'] ? Date.parse(modifier['to']) : nil
35
+ }
36
+ end
37
+
38
+ modifiers
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :data, :options, :output
44
+
45
+ def initialize(data, options)
46
+ @data = data
47
+ @options = options
48
+ @output = []
49
+ end
50
+
51
+ def modifiers?
52
+ @data.any? do |_, blocks|
53
+ blocks.any? do |block|
54
+ block[:transactions].any? do |_, transactions|
55
+ transactions.any? { |t| !t[:modifiers].empty? }
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def process_modifier
62
+ get_transactions.each do |modifier|
63
+ account = modifier[:account].ljust(@options[:max_category])
64
+ category = modifier[:category].ljust(@options[:max_category])
65
+ # Fix the ljust by counting strings in amount
66
+ amount = modifier[:amount].to_s.ljust(@options[:max_amount] - 1)
67
+ to = modifier[:to] ? "..#{modifier[:to]}" : nil
68
+
69
+ header = "= #{modifier[:category]} date:#{modifier[:from]}#{to}\n"
70
+ transactions = " #{category} *#{amount}; #{modifier[:description]}\n"
71
+ footer = " #{account} *#{modifier[:amount] * -1}\n\n"
72
+
73
+ output << { header: header, transactions: [transactions], footer: footer }
74
+ end
75
+ end
76
+
77
+ def get_transactions
78
+ @data.each_with_object([]) do |(_key, blocks), result|
79
+ blocks.each do |block|
80
+ block[:transactions].each_value do |transactions|
81
+ transactions.each do |t|
82
+ result.concat(t[:modifiers]) if t[:modifiers]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,87 @@
1
+ module HledgerForecast
2
+ module Transactions
3
+ # Generate hledger transactions based on the non-existance of a transaction
4
+ # in your ledger. This is useful for ensuring that certain expenses are
5
+ # accounted for, even if you forget to enter them.
6
+ #
7
+ # Example output:
8
+ # ~ 2023-05-1 * [TRACKED] Food expenses
9
+ # Expenses:Groceries $250.00 ; Food expenses
10
+ # Assets:Checking
11
+ class Trackers
12
+ def self.generate(data, options)
13
+ new(data, options).generate
14
+ end
15
+
16
+ def generate
17
+ return nil unless tracked?(data)
18
+
19
+ data.each_value do |blocks|
20
+ blocks.each do |block|
21
+ process_tracked(block)
22
+ end
23
+ end
24
+
25
+ output
26
+ end
27
+
28
+ def self.track?(transaction, data, options)
29
+ now = Date.today
30
+ transaction['track'] && Date.parse(data['from']) <= now && !exists?(transaction, data['account'],
31
+ data['from'], now, options)
32
+ end
33
+
34
+ def self.exists?(transaction, account, from, to, options)
35
+ # Format the money
36
+ amount = Formatter.format_money(transaction['amount'], options)
37
+ inverse_amount = Formatter.format_money(transaction['amount'] * -1, options)
38
+
39
+ category = transaction['category'].gsub('[', '\\[').gsub(']', '\\]').gsub('(', '\\(').gsub(')', '\\)')
40
+
41
+ # We run two commands and check to see if category +/- amount or account +/- amount exists
42
+ command1 = %(hledger print -f #{options[:transaction_file]} "date:#{from}..#{to}" | tr -s '[:space:]' ' ' | grep -q -Eo "#{category} (#{amount}|#{inverse_amount})")
43
+ command2 = %(hledger print -f #{options[:transaction_file]} "date:#{from}..#{to}" | tr -s '[:space:]' ' ' | grep -q -Eo "#{account} (#{amount}|#{inverse_amount})")
44
+
45
+ system(command1) || system(command2)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :data, :options, :output
51
+
52
+ def initialize(data, options)
53
+ @data = data
54
+ @options = options
55
+ @output = []
56
+ end
57
+
58
+ def tracked?(data)
59
+ data.any? do |_, blocks|
60
+ blocks.any? do |block|
61
+ block[:transactions].any? do |_, transactions|
62
+ transactions.any? { |t| t[:track] }
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def process_tracked(block)
69
+ block[:transactions].each do |_to, transactions|
70
+ transactions.each do |t|
71
+ next unless t[:track]
72
+
73
+ category = t[:category].ljust(options[:max_category])
74
+ amount = t[:amount].to_s.ljust(options[:max_amount])
75
+
76
+ header = "~ #{Date.new(Date.today.year, Date.today.month,
77
+ 1).next_month} * [TRACKED] #{t[:description]}\n"
78
+ transactions = " #{category} #{amount}; #{t[:description]}\n"
79
+ footer = " #{block[:account]}\n\n"
80
+
81
+ output << { header: header, transactions: [transactions], footer: footer }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,3 +1,3 @@
1
1
  module HledgerForecast
2
- VERSION = "0.4.0"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -12,8 +12,14 @@ require 'yaml'
12
12
  Money.locale_backend = nil
13
13
  Money.rounding_mode = BigDecimal::ROUND_HALF_UP
14
14
 
15
- require_relative 'hledger_forecast/version'
16
- require_relative 'hledger_forecast/generator'
17
- require_relative 'hledger_forecast/summarize'
18
- require_relative 'hledger_forecast/tracker'
15
+ require_relative 'hledger_forecast/calculator'
19
16
  require_relative 'hledger_forecast/cli'
17
+ require_relative 'hledger_forecast/formatter'
18
+ require_relative 'hledger_forecast/generator'
19
+ require_relative 'hledger_forecast/settings'
20
+ require_relative 'hledger_forecast/summarizer'
21
+ require_relative 'hledger_forecast/version'
22
+
23
+ require_relative 'hledger_forecast/transactions/default'
24
+ require_relative 'hledger_forecast/transactions/modifiers'
25
+ require_relative 'hledger_forecast/transactions/trackers'
data/spec/custom_spec.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative '../lib/hledger_forecast'
2
2
 
3
- config = <<~YAML
3
+ base_config = <<~YAML
4
4
  custom:
5
5
  - frequency: "every 2 weeks"
6
6
  from: "2023-05-01"
@@ -21,7 +21,7 @@ config = <<~YAML
21
21
  currency: GBP
22
22
  YAML
23
23
 
24
- output = <<~JOURNAL
24
+ base_output = <<~JOURNAL
25
25
  ~ every 2 weeks from 2023-05-01 * Hair and beauty
26
26
  [Expenses:Personal Care] £80.00; Hair and beauty
27
27
  [Assets:Bank]
@@ -32,9 +32,36 @@ output = <<~JOURNAL
32
32
 
33
33
  JOURNAL
34
34
 
35
+ calculated_config = <<~YAML
36
+ custom:
37
+ - frequency: "every 2 weeks"
38
+ from: "2023-05-01"
39
+ account: "[Assets:Bank]"
40
+ transactions:
41
+ - amount: 80
42
+ category: "[Expenses:Personal Care]"
43
+ description: Hair and beauty
44
+ to: "=6"
45
+
46
+ settings:
47
+ currency: GBP
48
+ YAML
49
+
50
+ calculated_output = <<~JOURNAL
51
+ ~ every 2 weeks from 2023-05-01 to 2023-10-31 * Hair and beauty
52
+ [Expenses:Personal Care] £80.00; Hair and beauty
53
+ [Assets:Bank]
54
+
55
+ JOURNAL
56
+
35
57
  RSpec.describe 'generate' do
36
58
  it 'generates a forecast with correct CUSTOM transactions' do
37
- generated_journal = HledgerForecast::Generator.generate(config)
38
- expect(generated_journal).to eq(output)
59
+ generated_journal = HledgerForecast::Generator.generate(base_config)
60
+ expect(generated_journal).to eq(base_output)
61
+ end
62
+
63
+ it 'generates a forecast with correct CUSTOM transactions and CALCULATED to dates' do
64
+ generated_journal = HledgerForecast::Generator.generate(calculated_config)
65
+ expect(generated_journal).to eq(calculated_output)
39
66
  end
40
67
  end
@@ -5,7 +5,7 @@ base_config = <<~YAML
5
5
  - account: "Assets:Bank"
6
6
  from: "2023-01-01"
7
7
  transactions:
8
- - amount: 500
8
+ - amount: 300
9
9
  category: "Expenses:Groceries"
10
10
  description: Food shopping
11
11
  modifiers:
@@ -17,6 +17,17 @@ base_config = <<~YAML
17
17
  description: "Y2 inflation"
18
18
  from: "2025-01-01"
19
19
  to: "2025-12-31"
20
+ - account: "Assets:Savings"
21
+ from: "2023-05-01"
22
+ transactions:
23
+ - amount: 500
24
+ category: "Assets:Bank"
25
+ description: Savings
26
+ modifiers:
27
+ - amount: 0.1
28
+ description: "Savings uplift"
29
+ from: "2024-05-01"
30
+ to: "2025-04-30"
20
31
 
21
32
  settings:
22
33
  currency: USD
@@ -24,9 +35,13 @@ YAML
24
35
 
25
36
  base_journal = <<~JOURNAL
26
37
  ~ monthly from 2023-01-01 * Food shopping
27
- Expenses:Groceries $500.00; Food shopping
38
+ Expenses:Groceries $300.00; Food shopping
28
39
  Assets:Bank
29
40
 
41
+ ~ monthly from 2023-05-01 * Savings
42
+ Assets:Bank $500.00; Savings
43
+ Assets:Savings
44
+
30
45
  = Expenses:Groceries date:2024-01-01..2024-12-31
31
46
  Expenses:Groceries *0.02 ; Food shopping - Y1 inflation
32
47
  Assets:Bank *-0.02
@@ -35,6 +50,10 @@ base_journal = <<~JOURNAL
35
50
  Expenses:Groceries *0.05 ; Food shopping - Y2 inflation
36
51
  Assets:Bank *-0.05
37
52
 
53
+ = Assets:Bank date:2024-05-01..2025-04-30
54
+ Assets:Bank *0.1 ; Savings - Savings uplift
55
+ Assets:Savings *-0.1
56
+
38
57
  JOURNAL
39
58
 
40
59
  no_date_config = <<~YAML
@@ -67,20 +86,16 @@ JOURNAL
67
86
  RSpec.describe 'Applying modifiers to transactions -' do
68
87
  it 'Auto-postings should be created correctly' do
69
88
  generated = HledgerForecast::Generator
70
- generated.modified = {} # Clear modified transactions
71
89
 
72
90
  generated_journal = generated.generate(base_config)
73
- generated.modified = {} # Clear modified transactions
74
91
 
75
92
  expect(generated_journal).to eq(base_journal)
76
93
  end
77
94
 
78
95
  it 'Auto-postings should be created correctly if no dates are set' do
79
96
  generated = HledgerForecast::Generator
80
- generated.modified = {} # Clear modified transactions
81
97
 
82
98
  generated_journal = generated.generate(no_date_config)
83
- generated.modified = {} # Clear modified transactions
84
99
 
85
100
  expect(generated_journal).to eq(no_date_journal)
86
101
  end
@@ -1,47 +1,32 @@
1
1
  require_relative '../lib/hledger_forecast'
2
2
 
3
3
  config = <<~YAML
4
- settings:
5
- currency: GBP
4
+ settings:
5
+ currency: GBP
6
6
 
7
- monthly:
8
- - from: "2023-03-01"
9
- account: "Assets:Bank"
10
- transactions:
11
- - description: Mortgage
12
- to: "2023-06-01"
13
- category: "Expenses:Mortgage"
14
- amount: 2000.00
15
- - description: Mortgage top up
16
- to: "2023-06-01"
17
- category: "Expenses:Mortgage Top Up"
18
- amount: 200.00
19
- - description: Food
20
- category: "Expenses:Food"
21
- amount: 100.00
22
- - description: Party time
23
- category: "Expenses:Going Out"
24
- amount: 50.00
7
+ monthly:
8
+ - from: "2023-03-01"
9
+ to: "2023-06-01"
10
+ account: "Assets:Bank"
11
+ transactions:
12
+ - description: Mortgage
13
+ category: "Expenses:Mortgage"
14
+ amount: 2000.00
15
+ - description: Food
16
+ category: "Expenses:Food"
17
+ amount: 100.00
25
18
  YAML
26
19
 
27
20
  output = <<~JOURNAL
28
- ~ monthly from 2023-03-01 * Food, Party time
29
- Expenses:Food £100.00 ; Food
30
- Expenses:Going Out £50.00 ; Party time
31
- Assets:Bank
32
-
33
- ~ monthly from 2023-03-01 to 2023-06-01 * Mortgage
34
- Expenses:Mortgage £2,000.00; Mortgage
35
- Assets:Bank
36
-
37
- ~ monthly from 2023-03-01 to 2023-06-01 * Mortgage top up
38
- Expenses:Mortgage Top Up £200.00 ; Mortgage top up
39
- Assets:Bank
21
+ ~ monthly from 2023-03-01 to 2023-06-01 * Mortgage, Food
22
+ Expenses:Mortgage £2,000.00; Mortgage
23
+ Expenses:Food £100.00 ; Food
24
+ Assets:Bank
40
25
 
41
26
  JOURNAL
42
27
 
43
28
  RSpec.describe 'generate' do
44
- it 'generates a forecast with correct MONTHLY transactions that have an end date' do
29
+ it 'generates a forecast with correct MONTHLY transactions that have an end date, at the top level' do
45
30
  expect(HledgerForecast::Generator.generate(config)).to eq(output)
46
31
  end
47
32
  end