hledger-forecast 0.4.0 → 1.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.
@@ -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