hledger-forecast 1.0.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.
- checksums.yaml +4 -4
- data/README.md +81 -27
- data/example.journal +33 -22
- data/example.yml +40 -26
- data/lib/hledger_forecast/cli.rb +26 -1
- data/lib/hledger_forecast/summarizer.rb +67 -93
- data/lib/hledger_forecast/summarizer_formatter.rb +115 -0
- data/lib/hledger_forecast/version.rb +1 -1
- data/lib/hledger_forecast.rb +2 -0
- data/spec/monthly_spec.rb +7 -7
- data/spec/summarizer_spec.rb +86 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 37dbc9f011b6a43fa1d186e92dfb77c3c184cdb4a7cc5ef690a77d53c57f4722
|
4
|
+
data.tar.gz: e71185f0ad678b45f1ffa155c89c491bd37d36307d845dd023b823db6ced42d4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d25713fb91fde68daaa07f6809a3c567afc18864a759e47fd032c31cd78cbe195eba2919deff4547d2fe6888e032f7394af4012aea839d1fc6b3d4ee7b1e94bf
|
7
|
+
data.tar.gz: 363185c38d788ae91528dc1fde6e21201fc19bec673848202a43589c36c8be3f6fe8e54cb367bfb8652ea286eda6513ab38e86e20aef99f8b7011cb1351fed3b
|
data/README.md
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
-
<
|
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
|
-
|
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
|
-
|
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
|
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:
|
20
|
-
- :
|
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
|
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.
|
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,10 @@ The available options are:
|
|
84
89
|
|
85
90
|
Usage: hledger-forecast summarize [options]
|
86
91
|
|
87
|
-
|
88
|
-
|
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
|
+
-h, --help Show this help message
|
89
96
|
|
90
97
|
## :gear: Configuration
|
91
98
|
|
@@ -183,9 +190,9 @@ monthly:
|
|
183
190
|
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
191
|
|
185
192
|
```yaml
|
186
|
-
- amount:
|
187
|
-
category: "Expenses:
|
188
|
-
description:
|
193
|
+
- amount: 125
|
194
|
+
category: "Expenses:Holiday"
|
195
|
+
description: Holiday
|
189
196
|
to: "=12"
|
190
197
|
```
|
191
198
|
|
@@ -219,13 +226,13 @@ To mark transactions as available for tracking you may use the `track` option in
|
|
219
226
|
|
220
227
|
```yaml
|
221
228
|
once:
|
222
|
-
account: "Assets:Bank"
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
+
- account: "Assets:Bank"
|
230
|
+
from: "2023-03-05"
|
231
|
+
transactions:
|
232
|
+
- amount: -3000
|
233
|
+
category: "Expenses:Shopping"
|
234
|
+
description: Refund for that damn laptop
|
235
|
+
track: true
|
229
236
|
```
|
230
237
|
|
231
238
|
> **Note**: This feature has been designed to work with one-off transactions only
|
@@ -275,6 +282,55 @@ modifiers:
|
|
275
282
|
to: "2025-12-31"
|
276
283
|
```
|
277
284
|
|
285
|
+
### Roll-ups
|
286
|
+
|
287
|
+
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?").
|
288
|
+
|
289
|
+
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:
|
290
|
+
|
291
|
+
```yaml
|
292
|
+
custom:
|
293
|
+
- frequency: "every 2 weeks"
|
294
|
+
account: "Assets:Bank"
|
295
|
+
from: "2023-03-01"
|
296
|
+
roll-up: 26
|
297
|
+
transactions:
|
298
|
+
- amount: 80
|
299
|
+
category: "Expenses:Personal Care"
|
300
|
+
description: Hair and beauty
|
301
|
+
```
|
302
|
+
|
303
|
+
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.
|
304
|
+
|
305
|
+
To see the monthly summary of your `yaml` file, the following command can be used:
|
306
|
+
|
307
|
+
hledger-forecast summarize -f my_forecast.yml -r monthly
|
308
|
+
|
309
|
+
You can also roll-up with the following periods:
|
310
|
+
|
311
|
+
- daily
|
312
|
+
- weekly
|
313
|
+
- monthly
|
314
|
+
- quarterly
|
315
|
+
- half-yearly
|
316
|
+
- yearly
|
317
|
+
|
318
|
+
### Summary exclusions
|
319
|
+
|
320
|
+
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:
|
321
|
+
|
322
|
+
```yaml
|
323
|
+
once:
|
324
|
+
- account: "Assets:Bank"
|
325
|
+
from: "2023-03-05"
|
326
|
+
transactions:
|
327
|
+
- amount: -3000
|
328
|
+
category: "Expenses:Shopping"
|
329
|
+
description: Refund for that damn laptop
|
330
|
+
summary_exclude: true
|
331
|
+
track: true
|
332
|
+
```
|
333
|
+
|
278
334
|
### Additional config settings
|
279
335
|
|
280
336
|
Additional settings in the config file to consider:
|
@@ -298,12 +354,10 @@ settings:
|
|
298
354
|
|
299
355
|
## :paintbrush: Rationale
|
300
356
|
|
301
|
-
|
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.
|
357
|
+
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
358
|
|
305
|
-
|
359
|
+
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
360
|
|
307
|
-
|
361
|
+
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
362
|
|
309
|
-
|
363
|
+
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 *
|
2
|
-
Income:
|
3
|
-
|
4
|
-
Expenses:Food
|
5
|
-
Expenses:
|
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
|
10
|
-
Expenses: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 *
|
14
|
-
|
15
|
-
Assets:
|
12
|
+
~ monthly from 2023-03-01 to 2024-02-29 * Holiday
|
13
|
+
Expenses:Holiday $125.00 ; Holiday
|
14
|
+
Assets:Bank
|
16
15
|
|
17
|
-
~
|
18
|
-
|
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
|
-
~
|
22
|
-
|
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
|
33
|
+
Income:Bonus $-2,000.00; Annual Bonus
|
27
34
|
Assets:Bank
|
28
35
|
|
29
|
-
~ every
|
30
|
-
Expenses:
|
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]
|
34
|
-
Expenses:
|
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
|
39
|
-
Assets:Bank
|
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: -
|
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.
|
19
|
+
- amount: 0.02
|
16
20
|
description: "Inflation"
|
17
21
|
from: "2024-01-01"
|
18
22
|
to: "2024-12-31"
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
25
|
-
|
26
|
-
|
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:
|
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:
|
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-
|
74
|
+
from: "2023-03-05"
|
63
75
|
transactions:
|
64
|
-
- amount:
|
65
|
-
category: "Expenses:
|
66
|
-
description:
|
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
|
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:
|
75
|
-
category: "Expenses:
|
76
|
-
description:
|
88
|
+
- amount: 80
|
89
|
+
category: "Expenses:Personal Care"
|
90
|
+
description: Hair and beauty
|
77
91
|
|
78
92
|
settings:
|
79
|
-
currency:
|
93
|
+
currency: USD
|
data/lib/hledger_forecast/cli.rb
CHANGED
@@ -118,6 +118,29 @@ 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("--from DATE",
|
128
|
+
"Include transactions that start FROM a given DATE [yyyy-mm-dd]") do |from|
|
129
|
+
options[:from] = from
|
130
|
+
end
|
131
|
+
|
132
|
+
opts.on("--to DATE",
|
133
|
+
"Include transactions that run TO a given DATE [yyyy-mm-dd]") do |to|
|
134
|
+
options[:to] = to
|
135
|
+
end
|
136
|
+
|
137
|
+
opts.on("-s", "--scenario \"NAMES\"",
|
138
|
+
"Include transactions from given scenarios, e.g.:",
|
139
|
+
"\"base, rennovation, car purchase\"") do |_scenario|
|
140
|
+
# Loop through scenarios, seperated by a comma
|
141
|
+
options[:scenario] = {}
|
142
|
+
end
|
143
|
+
|
121
144
|
opts.on_tail("-h", "--help", "Show this help message") do
|
122
145
|
puts opts
|
123
146
|
exit
|
@@ -156,7 +179,9 @@ module HledgerForecast
|
|
156
179
|
|
157
180
|
def self.summarize(options)
|
158
181
|
config = File.read(options[:forecast_file])
|
159
|
-
|
182
|
+
summarizer = Summarizer.summarize(config, options)
|
183
|
+
|
184
|
+
puts SummarizerFormatter.format(summarizer[:output], summarizer[:settings])
|
160
185
|
end
|
161
186
|
end
|
162
187
|
end
|
@@ -1,6 +1,5 @@
|
|
1
1
|
module HledgerForecast
|
2
|
-
# Summarise a forecast
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
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
|
-
|
37
|
+
output
|
33
38
|
end
|
34
39
|
|
35
|
-
def
|
36
|
-
|
37
|
-
@table.add_separator
|
38
|
-
end
|
40
|
+
def process_block(period, block)
|
41
|
+
output = []
|
39
42
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
-
|
52
|
+
process_transactions(period, block, output)
|
49
53
|
end
|
50
54
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
70
|
+
output
|
69
71
|
end
|
70
72
|
|
71
|
-
def
|
72
|
-
|
73
|
-
|
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
|
77
|
-
|
78
|
-
|
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
|
-
|
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
|
98
|
-
|
99
|
-
|
100
|
-
|
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,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
|
data/lib/hledger_forecast.rb
CHANGED
@@ -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
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
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.
|
4
|
+
version: 1.1.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-
|
11
|
+
date: 2023-05-20 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
|