hledger-forecast 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01faf9619f0b906b089956c1b81c16766830be412a678336848a7bbe23f78cb7
4
- data.tar.gz: c93786627631d0f589250a8c721d8360a9feddf6f9b21954cc49b0b6b206ff29
3
+ metadata.gz: 21be14ce391292a910e85657b5dd77e6e40648fc06a0c6ffe5d6aaabb8a4a52d
4
+ data.tar.gz: 7764b423d46ed8f656af7907374fbdac9519a63b92de6ac0fd8e85db80fdc715
5
5
  SHA512:
6
- metadata.gz: 218ecb7ea26f4daee36981d345cf4a67052b6c3876f4f2090d608a86a10ba7e07889010fa91ab3618da60bfac98caf2c48bbdda78ab68f0290e744a580f75076
7
- data.tar.gz: c3940bb307bbba32d374f0161ecaf5ca8bcdba2a1a0cafd0d1e4ea7f13ceea20a781dd0367eb02cd1c01fe73475f23fdcf1e520d3b76a9b7d875729971f221b7
6
+ metadata.gz: c52628b6b7259c24710e797a38f3bb01cfe85e4c199b26284d0e4aea7a2b2ec42f588cd69607b8a642c6637fd2d9d4a0d625c08e59c522f79bcd04a9a692b69d
7
+ data.tar.gz: dc8e7808c9f51444ffbc3d09af9845a2d971f9750cdd07c8210f56e6b84274e3b8a6159d5c09c718586b58d2655d8f463e97471dffe503bc4dddf26ca6557c2f
data/.gitignore CHANGED
@@ -18,3 +18,4 @@ pkg/*
18
18
  # Misc
19
19
  test_output.journal
20
20
  todo.md
21
+ .vscode
data/README.md CHANGED
@@ -1,4 +1,8 @@
1
- <h1 align="center">Hledger-Forecast</h1>
1
+ <p align="center">
2
+ <img src="https://github.com/olimorris/hledger-forecast/assets/9512444/5edb77e3-0ec6-4158-9b16-3978c1259879" alt="hledger-forecast" />
3
+ </p>
4
+
5
+ <h1 align="center">hledger-forecast</h1>
2
6
 
3
7
  <p align="center">
4
8
  <a href="https://github.com/olimorris/hledger-forecast/stargazers"><img src="https://img.shields.io/github/stars/olimorris/hledger-forecast?color=c678dd&logoColor=e06c75&style=for-the-badge"></a>
@@ -7,19 +11,18 @@
7
11
  <a href="https://github.com/olimorris/hledger-forecast/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/olimorris/hledger-forecast/ci.yml?branch=main&label=tests&style=for-the-badge"></a>
8
12
  </p>
9
13
 
10
- A wrapper which builds on [hledger's](https://github.com/simonmichael/hledger) [forecasting](https://hledger.org/dev/hledger.html#forecasting) capability. Uses a `yaml` config file to generate forecasts whilst adding functionality for future cost rises (e.g. inflation) and the automatic tracking of planned transactions.
14
+ **"Improved", you say?** Using a _yaml_ file, forecasts can be quickly generated into a _journal_ file ready to be fed into [hledger](https://github.com/simonmichael/hledger). Forecasts can be easily constrained between dates, inflated by modifiers, tracked until they appear in your bank statements and summarized into your own daily/weekly/monthly/yearly personal forecast income and expenditure statement.
11
15
 
12
- See the [rationale](#brain-rationale) section for why this gem may be useful to you.
16
+ I **strongly** recommend you read the [rationale](#paintbrush-rationale) section to see if this app might be useful to you.
13
17
 
14
18
  ## :sparkles: Features
15
19
 
16
20
  - :book: Uses a simple yaml file to generate forecasts which can be used with hledger
17
- - :date: Can smartly track forecasted transactions against actuals
21
+ - :date: Can smartly track forecasts against your bank statement
18
22
  - :moneybag: Can automatically apply modifiers such as inflation/deflation to forecasts
19
- - :abacus: Supports calculated amounts in forecasts (uses the [Dentaku](https://github.com/rubysolo/dentaku) gem)
20
- - :heavy_dollar_sign: Full currency support (uses the [RubyMoney](https://github.com/RubyMoney/money) gem)
23
+ - :abacus: Enables the use of maths in your forecasts (for amounts and dates)
24
+ - :chart_with_upwards_trend: Display your forecasts as income and expenditure reports (e.g. daily, weekly, monthly)
21
25
  - :computer: Simple and easy to use CLI
22
- - :chart_with_upwards_trend: Summarize your forecasts by period and category and output to the CLI
23
26
 
24
27
  ## :package: Installation
25
28
 
@@ -64,7 +67,7 @@ The available options are:
64
67
 
65
68
  Running the command with no options will assume a `forecast.yml` file exists.
66
69
 
67
- ### Using with Hledger
70
+ ### Using with hledger
68
71
 
69
72
  To work with hledger, include the forecast file and use the `--forecast` flag:
70
73
 
@@ -76,7 +79,9 @@ The command will generate a forecast up to the end of Feb 2024, showing the bala
76
79
 
77
80
  ### Summarize command
78
81
 
79
- As your `yaml` configuration file grows, it can be helpful to sum up the total amounts and output them to the CLI. This can be achieved by:
82
+ As your `yaml` configuration file grows, it can be helpful to sum up the total amounts and output them to the CLI.
83
+ Furthermore, being able to see your monthly profit and loss statement _if_ you were to purchase that new item may
84
+ influence your buying decision. In hledger-forecast, this can be achieved by:
80
85
 
81
86
  hledger-forecast summarize -f my_forecast.yml
82
87
 
@@ -84,8 +89,11 @@ The available options are:
84
89
 
85
90
  Usage: hledger-forecast summarize [options]
86
91
 
87
- -f, --forecast FILE The path to the FORECAST yaml file to summarize
88
- -h, --help Show this help message
92
+ -f, --forecast FILE The path to the FORECAST yaml file to summarize
93
+ -r, --roll-up PERIOD The period to roll-up your forecasts into. One of:
94
+ [yearly], [half-yearly], [quarterly], [monthly], [weekly], [daily]
95
+ -v, --verbose Show additional information in the summary
96
+ -h, --help Show this help message
89
97
 
90
98
  ## :gear: Configuration
91
99
 
@@ -183,9 +191,9 @@ monthly:
183
191
  It can also be useful to compute a `to` date by adding on a number of months to the `from` date. Extending the example above:
184
192
 
185
193
  ```yaml
186
- - amount: 2000
187
- category: "Expenses:Mortgage"
188
- description: Mortgage
194
+ - amount: 125
195
+ category: "Expenses:Holiday"
196
+ description: Holiday
189
197
  to: "=12"
190
198
  ```
191
199
 
@@ -219,13 +227,13 @@ To mark transactions as available for tracking you may use the `track` option in
219
227
 
220
228
  ```yaml
221
229
  once:
222
- account: "Assets:Bank"
223
- from: "2023-03-05"
224
- transactions:
225
- - amount: 3000
226
- category: "Expenses:Shopping"
227
- description: Refund for that damn laptop
228
- track: true
230
+ - account: "Assets:Bank"
231
+ from: "2023-03-05"
232
+ transactions:
233
+ - amount: -3000
234
+ category: "Expenses:Shopping"
235
+ description: Refund for that damn laptop
236
+ track: true
229
237
  ```
230
238
 
231
239
  > **Note**: This feature has been designed to work with one-off transactions only
@@ -275,6 +283,55 @@ modifiers:
275
283
  to: "2025-12-31"
276
284
  ```
277
285
 
286
+ ### Roll-ups
287
+
288
+ As part of the summarize command, it can be useful to sum-up all of the transactions in your `yaml` file and see what your income and expenditure is over a given period (e.g. "how much profit do I _actually_ make every year when all of my costs are taken into account?").
289
+
290
+ In order to do this, custom forecasts need to have the `roll-up` key defined. That is, given the custom period you've specified, what number do you need to multiply the amount by in order to "roll it up" into an annualised figure. Let's look at the example below:
291
+
292
+ ```yaml
293
+ custom:
294
+ - frequency: "every 2 weeks"
295
+ account: "Assets:Bank"
296
+ from: "2023-03-01"
297
+ roll-up: 26
298
+ transactions:
299
+ - amount: 80
300
+ category: "Expenses:Personal Care"
301
+ description: Hair and beauty
302
+ ```
303
+
304
+ Every 2 weeks a planned expense of £80 is made. So over the course of a year, we'd need to multiply that amount by 26 to get to an annualised figure. Of course for periods like `monthly` and `quarterly` it's easy for hledger-forecast to annualise those amounts so no `roll-up` is required.
305
+
306
+ To see the monthly summary of your `yaml` file, the following command can be used:
307
+
308
+ hledger-forecast summarize -f my_forecast.yml -r monthly
309
+
310
+ You can also roll-up with the following periods:
311
+
312
+ - daily
313
+ - weekly
314
+ - monthly
315
+ - quarterly
316
+ - half-yearly
317
+ - yearly
318
+
319
+ ### Summary exclusions
320
+
321
+ It can be useful to exclude certain items from your summary, like one-off items. This can be achieved by specifying `summary_exclude: true` next to a transaction:
322
+
323
+ ```yaml
324
+ once:
325
+ - account: "Assets:Bank"
326
+ from: "2023-03-05"
327
+ transactions:
328
+ - amount: -3000
329
+ category: "Expenses:Shopping"
330
+ description: Refund for that damn laptop
331
+ summary_exclude: true
332
+ track: true
333
+ ```
334
+
278
335
  ### Additional config settings
279
336
 
280
337
  Additional settings in the config file to consider:
@@ -298,12 +355,10 @@ settings:
298
355
 
299
356
  ## :paintbrush: Rationale
300
357
 
301
- Firstly, I've come to realise from reading countless blog and Reddit posts on [plain text accounting](https://plaintextaccounting.org), that everyone does it **completely** differently! There is _great_ support in hledger for [forecasting](https://hledger.org/1.29/hledger.html#forecasting) using periodic transactions. Infact, it's nearly perfect for my needs. My only wishes were to be able to sum up monthly transactions much faster (so I can see my forecasted monthly I&E), apply future cost pressures more easily (such as inflation) and to be able to track and monitor specific transactions.
302
-
303
- Regarding the latter; I may be expecting a material amount of money to leave my account in May (perhaps for a holiday booking). But maybe, that booking ends up leaving in July instead. Whilst I would have accounted for that expense in my forecast, it will be tied to some date in May. So if that transaction doesn't appear in the "actuals" of my May bank statement (which I import into hledger), it won't be included in my forecast at all (as the latest transaction period will be greater than the forecast period). The impact is that my forecasted balance in any future month could be $X better off than reality. Being able to automatically look out for these transactions, and include them if they're not present, is a nice time saver.
358
+ I moved to hledger from my trusty Excel macro workbook. This thing had been with me for 5+ years. I used it to workout whether I could afford that new gadget and when I'd be in a position to buy a house. I used it to see if I was on track to have £X in my savings accounts by a given date as well as see how much money I could save on a monthly basis. That time I accidentally double counted my bonus or thought I'd accounted for my credit card bill? Painful! Set me back a few months in terms of my savings plans. In summary, I relied _heavily_ on having a detailed and accurate forecast.
304
359
 
305
- Also, I like to look ahead up to 3 years at a time and understand what my bank balances might look like. For this to be really accurate, factors such as inflation and salary expectations should be included. This is where the idea for modifiers came in. Being able to apply a percentage to a given category between two dates and automatically have the impact included any extended forecasts.
360
+ I love hledger. Switching from Excel has been a breath of fresh air. There's only so many bank transactions a workbook can take before it starts groaning (yes, even on an M1 Mac). However there were a few forecasting features that I missed. The sort of features that in Excel terms mean I'd just copy a bunch of cells and paste them into columns which represented future dates or apply a neat little formula to divide a big number by 12 to get to a monthly repayment. Because I like to plan 3-5 years out at a time, I wanted to crudely account for future price and salary increases. Sure, I can add some auto-postings to the end of my journal file but I bet a lot of users didn't know about this or even know how to constrain them between two dates.
306
361
 
307
- Now I'll freely admit these are two minor issues. So minor infact that they can probably be addressed by a dedicated 5 minutes every month as part of your hledger workflow. However I liked the idea of automating as much of my month end process as possible and saw this as an interesting challenge to try and solve.
362
+ I also made an assumption that a lot of users probably think of their finances in terms of their monthly costs (e.g. car payments, mortgage, food), half-yearly costs (e.g. service charge if you have an apartment in the UK) and yearly costs (e.g. holidays, gifts) etc. But likely never do the math to add them all together and workout how much money they have left over by the end of it all. Well I built that into this app and my daily profit figure hit me hard :rofl:. Give it a try!
308
363
 
309
- Whilst I tried to work within the constraints of a `journal` file, moving to a `yaml` format made the implementation of these features much easier and allowed me to stay true to how you'd accomplish forecasting in hledger, manually. Whilst the config file can end up being many lines long, the output journal should be relatively streamlined and easy to follow.
364
+ So I thought I'd share this little Ruby gem in the hope that people find it useful. Perhaps for those who are moving from an Excel based approach to [plain text accounting](https://plaintextaccounting.org), or for those who want a little bit of improvement to the existing capabilities within hledger.
data/example.journal CHANGED
@@ -1,40 +1,51 @@
1
- ~ monthly from 2023-03-01 * Bonus, Salary, Food, New cell phone, Holiday savings
2
- Income:Bonus £-100.00 ; Bonus
3
- Income:Salary £-2,000.00; Salary
4
- Expenses:Food £500.00 ; Food
5
- Expenses:Phone £75.00 ; New cell phone
6
- Expenses:Holiday £208.33 ; Holiday savings
1
+ ~ monthly from 2023-03-01 * Salary, Bills, Food, New Kitchen
2
+ Income:Salary $-3,500.00; Salary
3
+ Expenses:Bills $175.00 ; Bills
4
+ Expenses:Food $500.00 ; Food
5
+ Expenses:House $208.33 ; New Kitchen
7
6
  Assets:Bank
8
7
 
9
- ~ monthly from 2023-03-01 to 2024-01-01 * Mortgage
10
- Expenses:Mortgage £1,000.00 ; Mortgage
8
+ ~ monthly from 2023-03-01 to 2025-01-01 * Mortgage
9
+ Expenses:Mortgage $2,000.00 ; Mortgage
11
10
  Assets:Bank
12
11
 
13
- ~ monthly from 2023-03-01 * Pension draw down
14
- Income:Pension £-500.00 ; Pension draw down
15
- Assets:Savings
12
+ ~ monthly from 2023-03-01 to 2024-02-29 * Holiday
13
+ Expenses:Holiday $125.00 ; Holiday
14
+ Assets:Bank
16
15
 
17
- ~ every 3 months from 2023-04-01 * Bonus
18
- Income:Bonus £-1,000.00; Bonus
16
+ ~ monthly from 2023-03-01 to 2025-01-01 * Rainy day fund
17
+ Assets:Savings $300.00 ; Rainy day fund
19
18
  Assets:Bank
20
19
 
21
- ~ every 6 months from 2023-04-01 * Holiday
22
- Expenses:Holiday £500.00 ; Holiday
20
+ ~ monthly from 2024-01-01 * Pension draw down
21
+ Income:Pension $-500.00 ; Pension draw down
22
+ Assets:Pension
23
+
24
+ ~ every 3 months from 2023-04-01 * Quarterly bonus
25
+ Income:Bonus $-1,000.00; Quarterly bonus
26
+ Assets:Bank
27
+
28
+ ~ every 6 months from 2023-04-01 * Top up holiday funds
29
+ Expenses:Holiday $500.00 ; Top up holiday funds
23
30
  Assets:Bank
24
31
 
25
32
  ~ yearly from 2023-04-01 * Annual Bonus
26
- Income:Bonus £-2,000.00; Annual Bonus
33
+ Income:Bonus $-2,000.00; Annual Bonus
27
34
  Assets:Bank
28
35
 
29
- ~ every 5 days from 2023-03-01 * Car fuel
30
- Expenses:Car:Fuel £150.00 ; Car fuel
36
+ ~ every 2 weeks from 2023-03-01 * Hair and beauty
37
+ Expenses:Personal Care $80.00 ; Hair and beauty
31
38
  Assets:Bank
32
39
 
33
- ~ 2023-06-01 * [TRACKED] Forecast new car cost
34
- Expenses:Car £5,000.00 ; Forecast new car cost
40
+ ~ 2023-06-01 * [TRACKED] Refund for that damn laptop
41
+ Expenses:Shopping $-3,000.00; Refund for that damn laptop
35
42
  Assets:Bank
36
43
 
37
44
  = Expenses:Food date:2024-01-01..2024-12-31
38
- Expenses:Food *0.1 ; Food - Inflation
39
- Assets:Bank *-0.1
45
+ Expenses:Food *0.02 ; Food - Inflation
46
+ Assets:Bank *-0.02
47
+
48
+ = Expenses:Food date:2025-01-01..2025-12-31
49
+ Expenses:Food *0.05 ; Food - Inflation
50
+ Assets:Bank *-0.05
40
51
 
data/example.yml CHANGED
@@ -2,32 +2,44 @@ monthly:
2
2
  - account: "Assets:Bank"
3
3
  from: "2023-03-01"
4
4
  transactions:
5
- - amount: -100
6
- category: "Income:Bonus"
7
- description: Bonus
8
- - amount: -2000
5
+ - amount: -3500
9
6
  category: "Income:Salary"
10
7
  description: Salary
8
+ - amount: 2000
9
+ category: "Expenses:Mortgage"
10
+ description: Mortgage
11
+ to: "2025-01-01"
12
+ - amount: 175
13
+ category: "Expenses:Bills"
14
+ description: Bills
11
15
  - amount: 500
12
16
  category: "Expenses:Food"
13
17
  description: Food
14
18
  modifiers:
15
- - amount: 0.1
19
+ - amount: 0.02
16
20
  description: "Inflation"
17
21
  from: "2024-01-01"
18
22
  to: "2024-12-31"
19
- - amount: 75
20
- category: "Expenses:Phone"
21
- description: New cell phone
22
- - amount: "=2500/12"
23
+ - amount: 0.05
24
+ description: "Inflation"
25
+ from: "2025-01-01"
26
+ to: "2025-12-31"
27
+ - amount: "=5000/24"
28
+ category: "Expenses:House"
29
+ description: New Kitchen
30
+ - amount: 125
23
31
  category: "Expenses:Holiday"
24
- description: Holiday savings
25
- - amount: 1000
26
- category: "Expenses:Mortgage"
27
- description: Mortgage
28
- to: "2024-01-01"
29
- - account: "Assets:Savings"
32
+ description: Holiday
33
+ to: "=12"
34
+ - account: "Assets:Bank"
30
35
  from: "2023-03-01"
36
+ to: "2025-01-01"
37
+ transactions:
38
+ - amount: 300
39
+ category: "Assets:Savings"
40
+ description: "Rainy day fund"
41
+ - account: "Assets:Pension"
42
+ from: "2024-01-01"
31
43
  transactions:
32
44
  - amount: -500
33
45
  category: "Income:Pension"
@@ -39,7 +51,7 @@ quarterly:
39
51
  transactions:
40
52
  - amount: -1000.00
41
53
  category: "Income:Bonus"
42
- description: Bonus
54
+ description: Quarterly bonus
43
55
 
44
56
  half-yearly:
45
57
  - account: "Assets:Bank"
@@ -47,7 +59,7 @@ half-yearly:
47
59
  transactions:
48
60
  - amount: 500
49
61
  category: "Expenses:Holiday"
50
- description: Holiday
62
+ description: Top up holiday funds
51
63
 
52
64
  yearly:
53
65
  - account: "Assets:Bank"
@@ -59,21 +71,23 @@ yearly:
59
71
 
60
72
  once:
61
73
  - account: "Assets:Bank"
62
- from: "2023-03-01"
74
+ from: "2023-03-05"
63
75
  transactions:
64
- - amount: 5000.00
65
- category: "Expenses:Car"
66
- description: Forecast new car cost
76
+ - amount: -3000
77
+ category: "Expenses:Shopping"
78
+ description: Refund for that damn laptop
79
+ summary_exclude: true
67
80
  track: true
68
81
 
69
82
  custom:
70
- - frequency: "every 5 days"
83
+ - frequency: "every 2 weeks"
71
84
  account: "Assets:Bank"
72
85
  from: "2023-03-01"
86
+ roll-up: 26
73
87
  transactions:
74
- - amount: 150
75
- category: "Expenses:Car:Fuel"
76
- description: Car fuel
88
+ - amount: 80
89
+ category: "Expenses:Personal Care"
90
+ description: Hair and beauty
77
91
 
78
92
  settings:
79
- currency: GBP
93
+ currency: USD
@@ -118,6 +118,34 @@ module HledgerForecast
118
118
  options[:forecast_file] = file
119
119
  end
120
120
 
121
+ opts.on("-r", "--roll-up PERIOD",
122
+ "The period to roll-up your forecasts into. One of:",
123
+ "[yearly], [half-yearly], [quarterly], [monthly], [weekly], [daily]") do |rollup|
124
+ options[:roll_up] = rollup
125
+ end
126
+
127
+ opts.on("-v", "--verbose",
128
+ "Show additional information in the summary") do |_|
129
+ options[:verbose] = true
130
+ end
131
+
132
+ # opts.on("--from DATE",
133
+ # "Include transactions that start FROM a given DATE [yyyy-mm-dd]") do |from|
134
+ # options[:from] = from
135
+ # end
136
+ #
137
+ # opts.on("--to DATE",
138
+ # "Include transactions that run TO a given DATE [yyyy-mm-dd]") do |to|
139
+ # options[:to] = to
140
+ # end
141
+
142
+ # opts.on("-s", "--scenario \"NAMES\"",
143
+ # "Include transactions from given scenarios, e.g.:",
144
+ # "\"base, rennovation, car purchase\"") do |_scenario|
145
+ # # Loop through scenarios, seperated by a comma
146
+ # options[:scenario] = {}
147
+ # end
148
+
121
149
  opts.on_tail("-h", "--help", "Show this help message") do
122
150
  puts opts
123
151
  exit
@@ -156,7 +184,9 @@ module HledgerForecast
156
184
 
157
185
  def self.summarize(options)
158
186
  config = File.read(options[:forecast_file])
159
- puts Summarizer.summarize(config, options)
187
+ summarizer = Summarizer.summarize(config, options)
188
+
189
+ puts SummarizerFormatter.format(summarizer[:output], summarizer[:settings])
160
190
  end
161
191
  end
162
192
  end
@@ -1,6 +1,5 @@
1
1
  module HledgerForecast
2
- # Summarise a forecast YAML file and output it to the CLI
3
- # TODO: Rename this to Summarizer and the main method becomes summarize
2
+ # Summarise a forecast yaml file and output it to the CLI
4
3
  class Summarizer
5
4
  def self.summarize(config, cli_options)
6
5
  new.summarize(config, cli_options)
@@ -9,124 +8,99 @@ module HledgerForecast
9
8
  def summarize(config, cli_options = nil)
10
9
  @forecast = YAML.safe_load(config)
11
10
  @settings = Settings.config(@forecast, cli_options)
12
- @table = Terminal::Table.new
13
11
 
14
- generate(@forecast)
12
+ return { output: generate(@forecast), settings: @settings }
15
13
  end
16
14
 
17
15
  private
18
16
 
19
17
  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)
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
25
32
  end
26
33
 
27
- add_categories_to_table(category_totals, forecast)
28
-
29
- @table.add_separator
30
- format_total("TOTAL", category_totals.values.map(&:values).flatten.sum)
34
+ output = filter_out(flatten_and_merge(output))
35
+ output = calculate_rolled_up_amount(output) unless @settings[:roll_up].nil?
31
36
 
32
- @table
37
+ output
33
38
  end
34
39
 
35
- def init_table
36
- @table.add_row([{ value: 'FORECAST SUMMARY'.bold, colspan: 3, alignment: :center }])
37
- @table.add_separator
38
- end
40
+ def process_block(period, block)
41
+ output = []
39
42
 
40
- def sum_transactions(forecast, period)
41
- category_total = Hash.new(0)
42
- forecast[period]&.each do |entry|
43
- entry['transactions'].each do |transaction|
44
- category_total[transaction['category']] += Calculator.new.evaluate(transaction['amount'])
45
- end
46
- end
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
+ }
47
51
 
48
- category_total
52
+ process_transactions(period, block, output)
49
53
  end
50
54
 
51
- def sum_custom_transactions(forecast_data)
52
- category_total = Hash.new(0)
53
- custom_periods = []
54
-
55
- forecast_data['custom']&.each do |entry|
56
- period_data = {}
57
- period_data[:frequency] = entry['frequency']
58
- period_data[:category] = entry['transactions'].first['category']
59
- period_data[:amount] = entry['transactions'].first['amount']
60
-
61
- entry['transactions'].each do |transaction|
62
- category_total[transaction['category']] += transaction['amount']
63
- end
64
-
65
- custom_periods << period_data
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
+ }
66
68
  end
67
69
 
68
- { totals: category_total, periods: custom_periods }
70
+ output
69
71
  end
70
72
 
71
- def format_amount(amount)
72
- formatted_amount = Formatter.format_money(amount, @settings)
73
- amount.to_f < 0 ? formatted_amount.green : formatted_amount.red
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]
74
85
  end
75
86
 
76
- def add_rows_to_table(row_data, period_total, custom: false)
77
- if custom
78
- row_data[:periods].each do |period|
79
- @table.add_row [{ value: period[:category], alignment: :left },
80
- { value: period[:frequency], alignment: :right },
81
- { value: format_amount(period[:amount]), alignment: :right }]
82
-
83
- period_total += period[:amount]
84
- end
85
- else
86
- row_data.each do |category, amount|
87
- @table.add_row [{ value: category, colspan: 2, alignment: :left },
88
- { value: format_amount(amount), alignment: :right }]
87
+ def filter_out(data)
88
+ data.reject { |item| item[:exclude] == true }
89
+ end
89
90
 
90
- period_total += amount
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)
91
95
  end
92
96
  end
93
-
94
- period_total
95
97
  end
96
98
 
97
- def add_categories_to_table(categories, forecast_data)
98
- first_period = true
99
- categories.each do |period, total|
100
- category_total = total.reject { |_, amount| amount == 0 }
101
- next if category_total.empty?
102
-
103
- sorted_category_total = sort_transactions(category_total)
104
-
105
- @table.add_separator unless first_period
106
- @table.add_row([{ value: period.capitalize.bold, colspan: 3, alignment: :center }])
107
-
108
- period_total = 0
109
- period_total += if period == 'custom'
110
- add_rows_to_table(sum_custom_transactions(forecast_data), period_total, custom: true)
111
- else
112
- add_rows_to_table(sorted_category_total, period_total)
113
- end
114
-
115
- format_total("#{period.capitalize} TOTAL", period_total)
116
- first_period = false
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
117
103
  end
118
104
  end
119
-
120
- def sort_transactions(category_total)
121
- negatives = category_total.select { |_, amount| amount < 0 }.sort_by { |_, amount| amount }
122
- positives = category_total.select { |_, amount| amount > 0 }.sort_by { |_, amount| -amount }
123
-
124
- negatives.concat(positives).to_h
125
- end
126
-
127
- def format_total(text, total)
128
- @table.add_row [{ value: text.bold, colspan: 2, alignment: :left },
129
- { value: format_amount(total).bold, alignment: :right }]
130
- end
131
105
  end
132
106
  end
@@ -0,0 +1,122 @@
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
+
46
+ if @settings[:verbose]
47
+ @table.add_row [{ value: item[:category], alignment: :left },
48
+ { value: item[:description], alignment: :left },
49
+ { value: format_amount(item[:amount]), alignment: :right }]
50
+ else
51
+ @table.add_row [{ value: item[:category], colspan: 2, alignment: :left },
52
+ { value: format_amount(item[:amount]), alignment: :right }]
53
+ end
54
+ end
55
+
56
+ @table.add_row [{ value: "TOTAL".bold, colspan: 2, alignment: :left },
57
+ { value: format_amount(total).bold, alignment: :right }]
58
+
59
+ @table.add_separator if index != data.size - 1
60
+ end
61
+ end
62
+
63
+ def sort(data)
64
+ data.each do |type, items|
65
+ data[type] = items.sort_by do |item|
66
+ value = item[:amount]
67
+ [value >= 0 ? 1 : 0, value >= 0 ? -value : value]
68
+ end
69
+ end
70
+ end
71
+
72
+ def add_rolled_up_rows_to_table(data)
73
+ sum_hash = Hash.new { |h, k| h[k] = { sum: 0, descriptions: [] } }
74
+
75
+ data.each do |item|
76
+ sum_hash[item[:category]][:sum] += item[:rolled_up_amount]
77
+ sum_hash[item[:category]][:descriptions] << item[:description]
78
+ end
79
+
80
+ # Convert arrays of descriptions to single strings
81
+ sum_hash.each do |_category, values|
82
+ values[:descriptions] = values[:descriptions].join(", ")
83
+ end
84
+
85
+ # Sort the array
86
+ sorted_sums = sort_roll_up(sum_hash, :sum)
87
+
88
+ sorted_sums.each do |hash|
89
+ @table.add_row [{ value: hash[:category], colspan: 2, alignment: :left },
90
+ { value: format_amount(hash[:sum]), alignment: :right }]
91
+ end
92
+ end
93
+
94
+ def sort_roll_up(data, sort_by)
95
+ # Convert the hash to an array of hashes
96
+ array = data.map do |category, values|
97
+ { category: category, sum: values[sort_by], descriptions: values[:descriptions] }
98
+ end
99
+
100
+ # Sort the array
101
+ array.sort_by do |hash|
102
+ value = hash[:sum]
103
+ [value >= 0 ? 1 : 0, value >= 0 ? -value : value]
104
+ end
105
+ end
106
+
107
+ def add_total_row_to_table(data, row_to_sum)
108
+ total = data.reduce(0) do |sum, item|
109
+ sum + item[row_to_sum]
110
+ end
111
+
112
+ @table.add_separator
113
+ @table.add_row [{ value: "TOTAL".bold, colspan: 2, alignment: :left },
114
+ { value: format_amount(total).bold, alignment: :right }]
115
+ end
116
+
117
+ def format_amount(amount)
118
+ formatted_amount = Formatter.format_money(amount, @settings)
119
+ amount.to_f < 0 ? formatted_amount.green : formatted_amount.red
120
+ end
121
+ end
122
+ end
@@ -1,3 +1,3 @@
1
1
  module HledgerForecast
2
- VERSION = "1.0.0"
2
+ VERSION = "1.2.0"
3
3
  end
@@ -18,8 +18,10 @@ require_relative 'hledger_forecast/formatter'
18
18
  require_relative 'hledger_forecast/generator'
19
19
  require_relative 'hledger_forecast/settings'
20
20
  require_relative 'hledger_forecast/summarizer'
21
+ require_relative 'hledger_forecast/summarizer_formatter'
21
22
  require_relative 'hledger_forecast/version'
22
23
 
23
24
  require_relative 'hledger_forecast/transactions/default'
24
25
  require_relative 'hledger_forecast/transactions/modifiers'
25
26
  require_relative 'hledger_forecast/transactions/trackers'
27
+
data/spec/monthly_spec.rb CHANGED
@@ -23,14 +23,14 @@ config = <<~YAML
23
23
  YAML
24
24
 
25
25
  output = <<~JOURNAL
26
- ~ monthly from 2023-03-01 * Mortgage, Food
27
- Expenses:Mortgage £2,000.55; Mortgage
28
- Expenses:Food £100.00 ; Food
29
- Assets:Bank
26
+ ~ monthly from 2023-03-01 * Mortgage, Food
27
+ Expenses:Mortgage £2,000.55; Mortgage
28
+ Expenses:Food £100.00 ; Food
29
+ Assets:Bank
30
30
 
31
- ~ monthly from 2023-03-01 * Savings
32
- Assets:Bank £-1,000.00; Savings
33
- Assets:Savings
31
+ ~ monthly from 2023-03-01 * Savings
32
+ Assets:Bank £-1,000.00; Savings
33
+ Assets:Savings
34
34
 
35
35
  JOURNAL
36
36
 
@@ -0,0 +1,86 @@
1
+ require_relative '../lib/hledger_forecast'
2
+
3
+ config = <<~YAML
4
+ monthly:
5
+ - account: "Assets:Bank"
6
+ from: "2023-03-01"
7
+ transactions:
8
+ - amount: 2000.55
9
+ category: "Expenses:Mortgage"
10
+ description: Mortgage
11
+ to: "=24"
12
+ - amount: 100
13
+ category: "Expenses:Food"
14
+ description: Food
15
+ - account: "Assets:Savings"
16
+ from: "2023-03-01"
17
+ transactions:
18
+ - amount: -1000
19
+ category: "Assets:Bank"
20
+ description: Savings
21
+
22
+ custom:
23
+ - frequency: "every 2 weeks"
24
+ from: "2023-05-01"
25
+ account: "[Assets:Bank]"
26
+ roll-up: 26
27
+ transactions:
28
+ - amount: 80
29
+ category: "[Expenses:Personal Care]"
30
+ description: Hair and beauty
31
+ - frequency: "every 5 days"
32
+ from: "2023-05-01"
33
+ account: "[Assets:Checking]"
34
+ roll-up: 73
35
+ transactions:
36
+ - amount: 50
37
+ category: "[Expenses:Groceries]"
38
+ description: Gotta feed that stomach
39
+
40
+ settings:
41
+ currency: GBP
42
+ YAML
43
+
44
+ RSpec.describe HledgerForecast::Summarizer do
45
+ let(:summarizer) { described_class.new }
46
+
47
+ describe '#generate with roll_up' do
48
+ let(:forecast) { YAML.safe_load(config) }
49
+ let(:cli_options) { { roll_up: 'monthly' } }
50
+
51
+ before do
52
+ summarizer.summarize(config, cli_options)
53
+ end
54
+
55
+ it 'generates the correct output' do
56
+ output = summarizer.send(:generate, forecast)
57
+
58
+ expect(output.first).to include(:account, :from, :to, :type, :frequency)
59
+ expect(output.first[:amount]).to eq(2000.55)
60
+ expect(output.last[:rolled_up_amount]).to eq(304) # ((50 * 73) / 12)
61
+ expect(output.length).to eq(5)
62
+ end
63
+
64
+ it 'transaction TO date take precedence over block TO date' do
65
+ output = summarizer.send(:generate, forecast)
66
+
67
+ expect(output.first[:to]).to eq(Date.parse("2025-02-28"))
68
+ end
69
+ end
70
+
71
+ describe '#generate' do
72
+ let(:forecast) { YAML.safe_load(config) }
73
+ let(:cli_options) { nil }
74
+
75
+ before do
76
+ summarizer.summarize(config, cli_options)
77
+ end
78
+
79
+ it 'generates the correct output' do
80
+ output = summarizer.send(:generate, forecast)
81
+
82
+ # expect(output.first).to include(:account, :from, :to, :type, :frequency)
83
+ expect(output.length).to eq(5)
84
+ end
85
+ end
86
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hledger-forecast
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oli Morris
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-15 00:00:00.000000000 Z
11
+ date: 2023-05-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: colorize
@@ -119,6 +119,7 @@ files:
119
119
  - lib/hledger_forecast/generator.rb
120
120
  - lib/hledger_forecast/settings.rb
121
121
  - lib/hledger_forecast/summarizer.rb
122
+ - lib/hledger_forecast/summarizer_formatter.rb
122
123
  - lib/hledger_forecast/transactions/default.rb
123
124
  - lib/hledger_forecast/transactions/modifiers.rb
124
125
  - lib/hledger_forecast/transactions/trackers.rb
@@ -137,6 +138,7 @@ files:
137
138
  - spec/stubs/transactions_found.journal
138
139
  - spec/stubs/transactions_found_inverse.journal
139
140
  - spec/stubs/transactions_not_found.journal
141
+ - spec/summarizer_spec.rb
140
142
  - spec/track_spec.rb
141
143
  - spec/yearly_spec.rb
142
144
  homepage: https://github.com/olimorris/hledger-forecast
@@ -177,5 +179,6 @@ test_files:
177
179
  - spec/stubs/transactions_found.journal
178
180
  - spec/stubs/transactions_found_inverse.journal
179
181
  - spec/stubs/transactions_not_found.journal
182
+ - spec/summarizer_spec.rb
180
183
  - spec/track_spec.rb
181
184
  - spec/yearly_spec.rb