hledger-forecast 3.3.0 → 3.4.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/.github/workflows/ci.yml +1 -1
- data/.github/workflows/manually_publish.yml +27 -0
- data/.github/workflows/release.yml +5 -3
- data/.gitignore +1 -0
- data/.release-please-manifest.json +3 -0
- data/AGENTS.md +29 -0
- data/CHANGELOG.md +14 -0
- data/README.md +42 -5
- data/lib/hledger_forecast/calculator.rb +11 -0
- data/lib/hledger_forecast/cli.rb +55 -25
- data/lib/hledger_forecast/generator.rb +1 -1
- data/lib/hledger_forecast/settings.rb +5 -1
- data/lib/hledger_forecast/summarizer.rb +6 -0
- data/lib/hledger_forecast/summarizer_exporter.rb +31 -0
- data/lib/hledger_forecast/summarizer_formatter.rb +1 -0
- data/lib/hledger_forecast/transaction.rb +9 -0
- data/lib/hledger_forecast/version.rb +1 -1
- data/lib/hledger_forecast.rb +1 -0
- data/release-please-config.json +11 -0
- data/spec/calculator_spec.rb +24 -0
- data/spec/cli_spec.rb +37 -1
- data/spec/computed_amounts_spec.rb +4 -1
- data/spec/monthly_end_date_spec.rb +4 -1
- data/spec/monthly_spec.rb +7 -1
- data/spec/summarizer_exporter_spec.rb +62 -0
- data/spec/summarizer_spec.rb +43 -0
- data/spec/tags_spec.rb +23 -5
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8f490f679ec154468837d25878ef658fd0225d73cdd30f291679475e247d00f
|
|
4
|
+
data.tar.gz: bcc7247a985c775b2b053c43d25f4e5269ed224ee6f396afdaa4339f6ddf4c52
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cf0966195c657ec869ecf442031c1d9a1e71dca2ebff8544efeb1fda027945a2ed3cbd280241148cfff276fc01fc08878a25f93876ba4c0e71ada81dae67b4c
|
|
7
|
+
data.tar.gz: 24de22a18a04df4041401495776267beeae94e5eb3e3ee95362b81216cf48e64f484d70bcad441751c63dc74d12400cdd601cbac5ed56f7958c266f43933af02
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Manually Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch: {}
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
manually_publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v7
|
|
12
|
+
|
|
13
|
+
- name: Set up Ruby
|
|
14
|
+
uses: ruby/setup-ruby@v1
|
|
15
|
+
with:
|
|
16
|
+
ruby-version: "3.3"
|
|
17
|
+
|
|
18
|
+
- name: Publish to RubyGems
|
|
19
|
+
run: |
|
|
20
|
+
mkdir -p $HOME/.gem
|
|
21
|
+
touch $HOME/.gem/credentials
|
|
22
|
+
chmod 0600 $HOME/.gem/credentials
|
|
23
|
+
printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
|
|
24
|
+
gem build *.gemspec
|
|
25
|
+
gem push *.gem
|
|
26
|
+
env:
|
|
27
|
+
GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}"
|
|
@@ -4,6 +4,7 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches:
|
|
6
6
|
- main
|
|
7
|
+
workflow_dispatch: {}
|
|
7
8
|
|
|
8
9
|
jobs:
|
|
9
10
|
release:
|
|
@@ -19,9 +20,10 @@ jobs:
|
|
|
19
20
|
steps:
|
|
20
21
|
- name: Release
|
|
21
22
|
id: release
|
|
22
|
-
uses: googleapis/release-please-action@
|
|
23
|
+
uses: googleapis/release-please-action@v5
|
|
23
24
|
with:
|
|
24
|
-
|
|
25
|
+
config-file: release-please-config.json
|
|
26
|
+
manifest-file: .release-please-manifest.json
|
|
25
27
|
|
|
26
28
|
publish:
|
|
27
29
|
runs-on: ubuntu-latest
|
|
@@ -29,7 +31,7 @@ jobs:
|
|
|
29
31
|
if: needs.release.outputs.release_created == 'true'
|
|
30
32
|
|
|
31
33
|
steps:
|
|
32
|
-
- uses: actions/checkout@
|
|
34
|
+
- uses: actions/checkout@v7
|
|
33
35
|
|
|
34
36
|
- name: Set up Ruby
|
|
35
37
|
uses: ruby/setup-ruby@v1
|
data/.gitignore
CHANGED
data/AGENTS.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# hledger-forecast
|
|
2
|
+
|
|
3
|
+
This is a RubyGem that enables users to use a CSV file as a forecast. This then feeds into hledger, generating entries into a journal file.
|
|
4
|
+
|
|
5
|
+
## Code formatting
|
|
6
|
+
|
|
7
|
+
After editing any Ruby files, run:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
rubyfmt spec lib -i
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Tests
|
|
14
|
+
|
|
15
|
+
Tests can be run with:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
rspec
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Non-verbose grouping
|
|
22
|
+
|
|
23
|
+
In non-verbose mode, `Generator.build_groups` (lib/hledger_forecast/generator.rb) merges CSV rows into a single hledger periodic transaction only when they share `[type, frequency, from, to, account, category]`. Rows with different categories always produce separate `~` entries, even if they share account/date - hledger treats postings within one transaction as linked, so bundling unrelated line items under one transaction misrepresents them in reports (e.g. `hledger register` shows every posting's account against every row, not just the filtered one). When touching grouping logic, check the effect through `hledger reg`/`register`, not just the raw journal text output.
|
|
24
|
+
|
|
25
|
+
## version.rb
|
|
26
|
+
|
|
27
|
+
`lib/hledger_forecast/version.rb` is bumped by release automation (release-please), not manually. If it shows as changed after running tests/tooling and you didn't intend a release bump, `git checkout` it rather than committing the bump.
|
|
28
|
+
|
|
29
|
+
The bump comes from the release PR, driven by `release-please-config.json`. Two settings there are load-bearing: `version-file` points at `version.rb`, and `package-name` is the gem name the `Gemfile.lock` updater matches on. Without them release-please writes only `CHANGELOG.md`, and because the gemspec reads `HledgerForecast::VERSION`, the publish job then pushes the previous version and RubyGems rejects it as a duplicate. Do not add a `release-type` input to `.github/workflows/release.yml` - it switches the action to standalone mode, which ignores the config file entirely.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.0](https://github.com/olimorris/hledger-forecast/compare/v3.3.1...v3.4.0) (2026-09-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* better summarisation ([#28](https://github.com/olimorris/hledger-forecast/issues/28)) ([619291d](https://github.com/olimorris/hledger-forecast/commit/619291d61bcb0aeb442edff4dd4bd382295629ab))
|
|
9
|
+
|
|
10
|
+
## [3.3.1](https://github.com/olimorris/hledger-forecast/compare/v3.3.0...v3.3.1) (2026-07-28)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* stop grouping transactions by category ([#21](https://github.com/olimorris/hledger-forecast/issues/21)) ([2a718ee](https://github.com/olimorris/hledger-forecast/commit/2a718ee506ea6b7e6058f5321351e959e4267f1c))
|
|
16
|
+
|
|
3
17
|
## [3.3.0](https://github.com/olimorris/hledger-forecast/compare/v3.2.0...v3.3.0) (2026-04-13)
|
|
4
18
|
|
|
5
19
|
|
data/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://github.com/
|
|
2
|
+
<img src="https://github.com/user-attachments/assets/f8582e29-c4f0-429d-9e3c-358fb719ed35" alt="hledger-forecast" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">hledger-forecast</h1>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<a href="https://github.com/olimorris/hledger-forecast/releases"><img src="https://img.shields.io/github/v/release/olimorris/hledger-forecast?style=for-the-badge"</a>
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Using a _CSV_ file, forecasts can be quickly generated into a _journal_ file ready to be fed into [hledger](https://github.com/simonmichael/hledger). **A 16 line [CSV file](https://github.com/olimorris/hledger-forecast/blob/main/example.csv) can generate a 46 line hledger [forecast file](https://github.com/olimorris/hledger-forecast/blob/main/example.journal)!**
|
|
14
14
|
|
|
15
15
|
## :sparkles: Features
|
|
16
16
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
Forecasting is essential to how I plan and budget with hledger. It enables me to know my financial position in 1 month, 10 months or even 100 months from now.
|
|
27
27
|
|
|
28
|
-
But forecasting in hledger is verbose and
|
|
28
|
+
But forecasting in hledger is verbose and manual. Consider this scenario: you purchase a new $3,000 laptop on a 0% finance deal and spread it over 20 months. In hledger, this would be accounted for with:
|
|
29
29
|
|
|
30
30
|
```ledger
|
|
31
31
|
~ monthly from 2026-01-01 to 2027-08-31 * New Laptop
|
|
@@ -107,9 +107,9 @@ This will generate a forecast up to the end of Feb 2027, showing asset balances
|
|
|
107
107
|
|
|
108
108
|
> **Tip:** If you use `hledger-ui`, the `--verbose` flag is worth using. It keeps each transaction as its own entry in the journal, making descriptions much easier to read in the UI.
|
|
109
109
|
|
|
110
|
-
###
|
|
110
|
+
### Summarise
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
It can be useful to summarise the entirety of your forecast into a profit and loss statement, rolling up periodic transactions accordingly. This is a great way of being able to see your monthly or yearly profit/loss. .
|
|
113
113
|
|
|
114
114
|
hledger-forecast summarize -f my_forecast.csv
|
|
115
115
|
|
|
@@ -120,8 +120,45 @@ As your forecast grows, it's useful to see the totals at a glance. Think of this
|
|
|
120
120
|
[yearly], [half-yearly], [quarterly], [monthly], [weekly], [daily]
|
|
121
121
|
-t, --tags TAGS Only include transactions with given tags (comma-separated)
|
|
122
122
|
-v, --verbose Show additional information in the summary
|
|
123
|
+
--from DATE Only include transactions still running from a given DATE,
|
|
124
|
+
e.g. "Sep 2027", "2027-09" or "01/09/2027"
|
|
125
|
+
--exclude-once Exclude one-off transactions from the summary
|
|
126
|
+
-e, --export FILE Also write the summary to a CSV FILE
|
|
127
|
+
--force Force an overwrite of the exported file
|
|
123
128
|
-h, --help Show this help message
|
|
124
129
|
|
|
130
|
+
**Scoping** - one-off purchases can distort the overall summary, since a `once` row is counted at its full value alongside your recurring commitments. They can be excluded with `--exclude-once`:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
hledger-forecast summarize -f forecast.csv --exclude-once
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
You can use the `--from` flag to enable questions like _"what does my P&L look like at this point in the future?"_. Anything that has finished by then - a mortgage with a `to` date in 2025, a one-off purchase in 2023 - is left out:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
hledger-forecast summarize -f forecast.csv --from="Sep 2027"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
You can also scope for specific tags:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
hledger-forecast summarize -f forecast.csv --tags=fixed
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The above would only show transactions that have been permanently tagged as `fixed` in the CSV file.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
hledger-forecast summarize -f forecast.csv --tags=-periodic
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Whereas the above would show everything _except_ transactions tagged as `periodic`.
|
|
155
|
+
|
|
156
|
+
**Exporting** - `--export` writes the summary to a CSV file as well as printing the table:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
hledger-forecast summarize -f forecast.csv --export summary.csv
|
|
160
|
+
```
|
|
161
|
+
|
|
125
162
|
### Compare
|
|
126
163
|
|
|
127
164
|
A core part of managing personal finances is comparing what you _expected_ to happen with what _actually_ happened. The `compare` command makes this easy:
|
|
@@ -22,6 +22,17 @@ module HledgerForecast
|
|
|
22
22
|
date >> @calc.evaluate(offset_expr).to_i
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Parses a user-supplied date such as "Sep 2027", "2027-09" or "01/09/2027"
|
|
26
|
+
def self.parse_date(value)
|
|
27
|
+
value = value.to_s.strip
|
|
28
|
+
value = "#{value}-01" if value.match?(/\A\d{4}-\d{1,2}\z/)
|
|
29
|
+
value = "#{value}-01-01" if value.match?(/\A\d{4}\z/)
|
|
30
|
+
|
|
31
|
+
Date.parse(value)
|
|
32
|
+
rescue Date::Error
|
|
33
|
+
raise ArgumentError, "invalid date '#{value}'"
|
|
34
|
+
end
|
|
35
|
+
|
|
25
36
|
def self.evaluate_date(from, to)
|
|
26
37
|
return (from >> to) - 1 if to.is_a?(Numeric)
|
|
27
38
|
return Date.parse(to) unless to.start_with?("=") || to.start_with?("+")
|
data/lib/hledger_forecast/cli.rb
CHANGED
|
@@ -185,15 +185,39 @@ module HledgerForecast
|
|
|
185
185
|
options[:verbose] = true
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
188
|
+
opts
|
|
189
|
+
.on(
|
|
190
|
+
"--from DATE",
|
|
191
|
+
"Only include transactions still running from a given DATE,",
|
|
192
|
+
"e.g. \"Sep 2027\", \"2027-09\" or \"01/09/2027\""
|
|
193
|
+
) do |from|
|
|
194
|
+
options[:from] = Calculator.parse_date(from)
|
|
195
|
+
rescue ArgumentError => e
|
|
196
|
+
puts("\nError: ".bold.red + e.message)
|
|
197
|
+
exit(1)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
opts.on(
|
|
201
|
+
"--exclude-once",
|
|
202
|
+
"Exclude one-off transactions from the summary"
|
|
203
|
+
) do
|
|
204
|
+
options[:exclude_once] = true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
opts.on(
|
|
208
|
+
"-e",
|
|
209
|
+
"--export FILE",
|
|
210
|
+
"Also write the summary to a CSV FILE"
|
|
211
|
+
) do |file|
|
|
212
|
+
options[:export] = file
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
opts.on(
|
|
216
|
+
"--force",
|
|
217
|
+
"Force an overwrite of the exported file"
|
|
218
|
+
) do
|
|
219
|
+
options[:force] = true
|
|
220
|
+
end
|
|
197
221
|
|
|
198
222
|
# opts.on("-s", "--scenario \"NAMES\"",
|
|
199
223
|
# "Include transactions from given scenarios, e.g.:",
|
|
@@ -261,22 +285,7 @@ module HledgerForecast
|
|
|
261
285
|
exit(1)
|
|
262
286
|
end
|
|
263
287
|
|
|
264
|
-
output_file
|
|
265
|
-
|
|
266
|
-
if File.exist?(output_file) && !options[:force]
|
|
267
|
-
print("\nFile '#{output_file}' already exists. Overwrite? (y/n): ")
|
|
268
|
-
overwrite = gets.chomp.downcase
|
|
269
|
-
|
|
270
|
-
if overwrite == "y"
|
|
271
|
-
File.write(output_file, transactions)
|
|
272
|
-
puts("\nSuccess: ".bold.green + "File '#{output_file}' has been overwritten.")
|
|
273
|
-
else
|
|
274
|
-
puts("\nInfo: ".bold.blue + "Operation aborted. File '#{output_file}' was not overwritten.")
|
|
275
|
-
end
|
|
276
|
-
else
|
|
277
|
-
File.write(output_file, transactions)
|
|
278
|
-
puts("\nSuccess: ".bold.green + "File '#{output_file}' has been created")
|
|
279
|
-
end
|
|
288
|
+
write_file(options[:output_file], transactions, options[:force])
|
|
280
289
|
end
|
|
281
290
|
|
|
282
291
|
def self.summarize(options)
|
|
@@ -291,6 +300,27 @@ module HledgerForecast
|
|
|
291
300
|
end
|
|
292
301
|
|
|
293
302
|
puts(SummarizerFormatter.format(summarizer[:output], summarizer[:settings]))
|
|
303
|
+
|
|
304
|
+
return unless options[:export]
|
|
305
|
+
|
|
306
|
+
csv = SummarizerExporter.export(summarizer[:output], summarizer[:settings])
|
|
307
|
+
write_file(options[:export], csv, options[:force])
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
private_class_method def self.write_file(path, contents, force)
|
|
311
|
+
if File.exist?(path) && !force
|
|
312
|
+
print("\nFile '#{path}' already exists. Overwrite? (y/n): ")
|
|
313
|
+
|
|
314
|
+
unless gets.chomp.downcase == "y"
|
|
315
|
+
return puts("\nInfo: ".bold.blue + "Operation aborted. File '#{path}' was not overwritten.")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
File.write(path, contents)
|
|
319
|
+
return puts("\nSuccess: ".bold.green + "File '#{path}' has been overwritten.")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
File.write(path, contents)
|
|
323
|
+
puts("\nSuccess: ".bold.green + "File '#{path}' has been created")
|
|
294
324
|
end
|
|
295
325
|
|
|
296
326
|
def self.compare(options)
|
|
@@ -26,7 +26,7 @@ module HledgerForecast
|
|
|
26
26
|
end
|
|
27
27
|
else
|
|
28
28
|
transactions
|
|
29
|
-
.group_by { |t| [t.type, t.frequency, t.from, t.to, t.account] }
|
|
29
|
+
.group_by { |t| [t.type, t.frequency, t.from, t.to, t.account, t.category] }
|
|
30
30
|
.map do |(type, frequency, from, to, account), txns|
|
|
31
31
|
TransactionGroup.new(
|
|
32
32
|
type: type,
|
|
@@ -13,7 +13,9 @@ module HledgerForecast
|
|
|
13
13
|
:sign_before_symbol,
|
|
14
14
|
:thousands_separator,
|
|
15
15
|
:verbose,
|
|
16
|
-
:roll_up
|
|
16
|
+
:roll_up,
|
|
17
|
+
:from,
|
|
18
|
+
:exclude_once
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
def self.parse(settings_rows, cli_options = nil)
|
|
@@ -38,6 +40,8 @@ module HledgerForecast
|
|
|
38
40
|
DEFAULTS[:thousands_separator]
|
|
39
41
|
@verbose = opts[:verbose] || false
|
|
40
42
|
@roll_up = opts[:roll_up]
|
|
43
|
+
@from = opts[:from]
|
|
44
|
+
@exclude_once = opts[:exclude_once] || false
|
|
41
45
|
end
|
|
42
46
|
end
|
|
43
47
|
end
|
|
@@ -8,6 +8,12 @@ module HledgerForecast
|
|
|
8
8
|
forecast = Forecast.parse(csv_string, cli_options)
|
|
9
9
|
transactions = forecast.transactions.reject(&:summary_exclude?)
|
|
10
10
|
|
|
11
|
+
transactions = transactions.reject(&:once?) if forecast.settings.exclude_once
|
|
12
|
+
|
|
13
|
+
if (from = forecast.settings.from)
|
|
14
|
+
transactions = transactions.select { |t| t.active_on_or_after?(from) }
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
if cli_options&.dig(:tags)
|
|
12
18
|
raise "The --tags option requires a 'tag' column in the forecast CSV" unless forecast.has_tags_column?
|
|
13
19
|
transactions = transactions.select { |t| t.matches_tags?(cli_options[:tags]) }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module HledgerForecast
|
|
2
|
+
# Renders the summary as CSV, mirroring the forecast file's columns and
|
|
3
|
+
# appending the amounts the summarizer calculated
|
|
4
|
+
class SummarizerExporter
|
|
5
|
+
HEADERS = %i[type frequency account from to description category amount annualised_amount].freeze
|
|
6
|
+
MONEY_FIELDS = %i[amount annualised_amount rolled_up_amount].freeze
|
|
7
|
+
|
|
8
|
+
def self.export(output, settings)
|
|
9
|
+
new.export(output, settings)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def export(output, settings)
|
|
13
|
+
headers = settings.roll_up ? HEADERS + [:rolled_up_amount] : HEADERS
|
|
14
|
+
|
|
15
|
+
CSV.generate do |csv|
|
|
16
|
+
csv << headers
|
|
17
|
+
output.each { |row| csv << headers.map { |h| format_value(h, row[h]) } }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def format_value(key, value)
|
|
24
|
+
return nil if value.nil?
|
|
25
|
+
return value.iso8601 if value.is_a?(Date)
|
|
26
|
+
return value.to_f.round(2) if MONEY_FIELDS.include?(key)
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -25,6 +25,7 @@ module HledgerForecast
|
|
|
25
25
|
|
|
26
26
|
def init_table
|
|
27
27
|
title = "FORECAST SUMMARY"
|
|
28
|
+
title += " FROM #{@settings.from.strftime("%b %Y").upcase}" if @settings.from
|
|
28
29
|
title += " (#{@settings.roll_up.upcase} ROLL UP)" if @settings.roll_up
|
|
29
30
|
|
|
30
31
|
@table.add_row([{value: title.bold, colspan: 3, alignment: :center}])
|
|
@@ -67,6 +67,15 @@ module HledgerForecast
|
|
|
67
67
|
|
|
68
68
|
def summary_exclude? = !!summary_exclude
|
|
69
69
|
|
|
70
|
+
def once? = type == "once"
|
|
71
|
+
|
|
72
|
+
# A `once` transaction has no TO date but only ever occurs on its FROM date
|
|
73
|
+
def ends_on = to || (once? ? from : nil)
|
|
74
|
+
|
|
75
|
+
def active_on_or_after?(date)
|
|
76
|
+
ends_on.nil? || ends_on >= date
|
|
77
|
+
end
|
|
78
|
+
|
|
70
79
|
def self.validate_required_fields!(row)
|
|
71
80
|
%i[type account from category amount].each do |field|
|
|
72
81
|
next unless row[field].nil? || row[field].to_s.strip.empty?
|
data/lib/hledger_forecast.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "hledger_forecast/formatter"
|
|
|
21
21
|
require_relative "hledger_forecast/generator"
|
|
22
22
|
require_relative "hledger_forecast/summarizer"
|
|
23
23
|
require_relative "hledger_forecast/summarizer_formatter"
|
|
24
|
+
require_relative "hledger_forecast/summarizer_exporter"
|
|
24
25
|
require_relative "hledger_forecast/comparator"
|
|
25
26
|
require_relative "hledger_forecast/cli"
|
|
26
27
|
require_relative "hledger_forecast/version"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"include-component-in-tag": false,
|
|
4
|
+
"packages": {
|
|
5
|
+
".": {
|
|
6
|
+
"release-type": "ruby",
|
|
7
|
+
"package-name": "hledger-forecast",
|
|
8
|
+
"version-file": "lib/hledger_forecast/version.rb"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
data/spec/calculator_spec.rb
CHANGED
|
@@ -69,3 +69,27 @@ RSpec.describe HledgerForecast::Calculator do
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
RSpec.describe HledgerForecast::Calculator do
|
|
74
|
+
describe ".parse_date" do
|
|
75
|
+
it "parses a month and year" do
|
|
76
|
+
expect(described_class.parse_date("Sep 2027")).to(eq(Date.new(2027, 9, 1)))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "parses an ISO year-month" do
|
|
80
|
+
expect(described_class.parse_date("2027-09")).to(eq(Date.new(2027, 9, 1)))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "parses a bare year" do
|
|
84
|
+
expect(described_class.parse_date("2027")).to(eq(Date.new(2027, 1, 1)))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "parses a day-first date" do
|
|
88
|
+
expect(described_class.parse_date("15/09/2027")).to(eq(Date.new(2027, 9, 15)))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "raises on an unparseable value" do
|
|
92
|
+
expect { described_class.parse_date("nonsense") }.to(raise_error(ArgumentError, /invalid date/))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/spec/cli_spec.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require_relative "../lib/hledger_forecast"
|
|
2
2
|
|
|
3
3
|
output = <<~JOURNAL
|
|
4
|
-
~ monthly from 2023-03-01 * Mortgage
|
|
4
|
+
~ monthly from 2023-03-01 * Mortgage
|
|
5
5
|
Expenses:Mortgage £2,000.55
|
|
6
|
+
Assets:Bank
|
|
7
|
+
|
|
8
|
+
~ monthly from 2023-03-01 * Food
|
|
6
9
|
Expenses:Food £100.00
|
|
7
10
|
Assets:Bank
|
|
8
11
|
|
|
@@ -35,6 +38,39 @@ RSpec.describe "command" do
|
|
|
35
38
|
expect(File.read(generated_journal)).to(eq(output))
|
|
36
39
|
end
|
|
37
40
|
|
|
41
|
+
it "uses the CLI to export a summary to a CSV file" do
|
|
42
|
+
exported_csv = "./test_summary.csv"
|
|
43
|
+
File.delete(exported_csv) if File.exist?(exported_csv)
|
|
44
|
+
|
|
45
|
+
system("./bin/hledger-forecast summarize -f ./spec/stubs/forecast.csv --export #{exported_csv} > /dev/null")
|
|
46
|
+
|
|
47
|
+
expect(File.read(exported_csv)).to(
|
|
48
|
+
eq(
|
|
49
|
+
<<~CSV
|
|
50
|
+
type,frequency,account,from,to,description,category,amount,annualised_amount
|
|
51
|
+
monthly,,Assets:Bank,2023-03-01,,Mortgage,Expenses:Mortgage,2000.55,24006.6
|
|
52
|
+
monthly,,Assets:Bank,2023-03-01,,Food,Expenses:Food,100.0,1200.0
|
|
53
|
+
monthly,,Assets:Savings,2023-03-01,,Savings,Assets:Bank,-1000.0,-12000.0
|
|
54
|
+
CSV
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
ensure
|
|
58
|
+
File.delete(exported_csv) if File.exist?(exported_csv)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "does not overwrite an existing export without confirmation" do
|
|
62
|
+
exported_csv = "./test_summary.csv"
|
|
63
|
+
File.write(exported_csv, "untouched")
|
|
64
|
+
|
|
65
|
+
system(
|
|
66
|
+
"echo n | ./bin/hledger-forecast summarize -f ./spec/stubs/forecast.csv --export #{exported_csv} > /dev/null"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect(File.read(exported_csv)).to(eq("untouched"))
|
|
70
|
+
ensure
|
|
71
|
+
File.delete(exported_csv) if File.exist?(exported_csv)
|
|
72
|
+
end
|
|
73
|
+
|
|
38
74
|
it "uses the CLI to compare two CSV files" do
|
|
39
75
|
expected_output = strip_ansi_codes(
|
|
40
76
|
<<~OUTPUT
|
|
@@ -9,8 +9,11 @@ config = <<~CSV
|
|
|
9
9
|
CSV
|
|
10
10
|
|
|
11
11
|
output = <<~JOURNAL
|
|
12
|
-
~ monthly from 2023-05-01 * New kitchen
|
|
12
|
+
~ monthly from 2023-05-01 * New kitchen
|
|
13
13
|
Expenses:House £208.33
|
|
14
|
+
Liabilities:Amex
|
|
15
|
+
|
|
16
|
+
~ monthly from 2023-05-01 * Monthly food shop, Random food
|
|
14
17
|
Expenses:Food £107.50
|
|
15
18
|
Expenses:Food £52.88
|
|
16
19
|
Liabilities:Amex
|
|
@@ -8,8 +8,11 @@ config = <<~CSV
|
|
|
8
8
|
CSV
|
|
9
9
|
|
|
10
10
|
output = <<~JOURNAL
|
|
11
|
-
~ monthly from 2023-03-01 to 2023-06-01 * Mortgage
|
|
11
|
+
~ monthly from 2023-03-01 to 2023-06-01 * Mortgage
|
|
12
12
|
Expenses:Mortgage £2,000.00
|
|
13
|
+
Assets:Bank
|
|
14
|
+
|
|
15
|
+
~ monthly from 2023-03-01 to 2023-06-01 * Food
|
|
13
16
|
Expenses:Food £100.00
|
|
14
17
|
Assets:Bank
|
|
15
18
|
|
data/spec/monthly_spec.rb
CHANGED
|
@@ -9,9 +9,15 @@ config = <<~CSV
|
|
|
9
9
|
CSV
|
|
10
10
|
|
|
11
11
|
output = <<~JOURNAL
|
|
12
|
-
~ monthly from 2023-03-01 * Bills
|
|
12
|
+
~ monthly from 2023-03-01 * Bills
|
|
13
13
|
Expenses:Bills £175.00
|
|
14
|
+
Assets:Bank
|
|
15
|
+
|
|
16
|
+
~ monthly from 2023-03-01 * Food
|
|
14
17
|
Expenses:Food £500.00
|
|
18
|
+
Assets:Bank
|
|
19
|
+
|
|
20
|
+
~ monthly from 2023-03-01 * Savings
|
|
15
21
|
Assets:Savings £-1,000.00
|
|
16
22
|
Assets:Bank
|
|
17
23
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "../lib/hledger_forecast"
|
|
2
|
+
|
|
3
|
+
EXPORT_CONFIG = <<~CSV
|
|
4
|
+
type,frequency,account,from,to,description,category,amount,roll-up,summary_exclude
|
|
5
|
+
monthly,,Assets:Bank,01/03/2023,01/03/2025,Mortgage,Expenses:Mortgage,2000.555,,
|
|
6
|
+
monthly,,Assets:Bank,01/03/2023,,"Food, drink",Expenses:Food,100,,
|
|
7
|
+
custom,every 2 weeks,Assets:Bank,01/05/2023,,Hair,Expenses:Personal Care,80,26,
|
|
8
|
+
settings,currency,GBP,,,,,,,,
|
|
9
|
+
CSV
|
|
10
|
+
|
|
11
|
+
RSpec.describe HledgerForecast::SummarizerExporter do
|
|
12
|
+
def exported(options = {})
|
|
13
|
+
summary = HledgerForecast::Summarizer.summarize(EXPORT_CONFIG, options)
|
|
14
|
+
CSV.parse(described_class.export(summary[:output], summary[:settings]), headers: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "writes a header row mirroring the forecast columns" do
|
|
18
|
+
expect(exported.headers).to(
|
|
19
|
+
eq(%w[type frequency account from to description category amount annualised_amount])
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "writes a row per transaction" do
|
|
24
|
+
expect(exported.length).to(eq(3))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "formats dates as ISO 8601" do
|
|
28
|
+
expect(exported[0]["from"]).to(eq("2023-03-01"))
|
|
29
|
+
expect(exported[0]["to"]).to(eq("2025-03-01"))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "leaves an open-ended TO date blank" do
|
|
33
|
+
expect(exported[1]["to"]).to(be_nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "rounds amounts to two decimal places" do
|
|
37
|
+
expect(exported[0]["amount"]).to(eq("2000.56"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "writes amounts without currency formatting" do
|
|
41
|
+
expect(exported[0]["annualised_amount"]).to(eq("24006.66"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "quotes fields containing a comma" do
|
|
45
|
+
expect(exported[1]["description"]).to(eq("Food, drink"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "adds a rolled_up_amount column when rolling up" do
|
|
49
|
+
rolled = exported({roll_up: "monthly"})
|
|
50
|
+
|
|
51
|
+
expect(rolled.headers).to(include("rolled_up_amount"))
|
|
52
|
+
expect(rolled[2]["rolled_up_amount"]).to(eq(((80.0 * 26) / 12).round(2).to_s))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "omits the rolled_up_amount column without a roll-up" do
|
|
56
|
+
expect(exported.headers).not_to(include("rolled_up_amount"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "only exports rows that survived the filters" do
|
|
60
|
+
expect(exported({from: Date.new(2026, 1, 1)}).map { |r| r["description"] }).to(eq(["Food, drink", "Hair"]))
|
|
61
|
+
end
|
|
62
|
+
end
|
data/spec/summarizer_spec.rb
CHANGED
|
@@ -48,3 +48,46 @@ RSpec.describe HledgerForecast::Summarizer do
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
|
+
|
|
52
|
+
FILTER_CONFIG = <<~CSV
|
|
53
|
+
type,frequency,account,from,to,description,category,amount,roll-up,summary_exclude
|
|
54
|
+
monthly,,Assets:Bank,01/03/2023,,Food,Expenses:Food,100,,
|
|
55
|
+
monthly,,Assets:Bank,01/03/2023,01/01/2025,Mortgage,Expenses:Mortgage,2000,,
|
|
56
|
+
yearly,,Assets:Bank,01/04/2023,01/06/2028,Insurance,Expenses:Insurance,500,,
|
|
57
|
+
once,,Assets:Bank,05/03/2023,,Laptop refund,Expenses:Shopping,-3000,,
|
|
58
|
+
once,,Assets:Bank,05/03/2029,,New car,Expenses:Car,10000,,
|
|
59
|
+
CSV
|
|
60
|
+
|
|
61
|
+
RSpec.describe HledgerForecast::Summarizer do
|
|
62
|
+
def descriptions(options)
|
|
63
|
+
described_class.summarize(FILTER_CONFIG, options)[:output].map { |r| r[:description] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe "#summarize with exclude_once" do
|
|
67
|
+
it "removes one-off transactions" do
|
|
68
|
+
expect(descriptions({exclude_once: true})).to(eq(["Food", "Mortgage", "Insurance"]))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "keeps them by default" do
|
|
72
|
+
expect(descriptions({})).to(include("Laptop refund", "New car"))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe "#summarize with from" do
|
|
77
|
+
it "keeps transactions that are still running" do
|
|
78
|
+
expect(descriptions({from: Date.new(2027, 9, 1)})).to(eq(["Food", "Insurance", "New car"]))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "drops a one-off that has already happened" do
|
|
82
|
+
expect(descriptions({from: Date.new(2023, 4, 1)})).not_to(include("Laptop refund"))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "drops a transaction that ends before the date" do
|
|
86
|
+
expect(descriptions({from: Date.new(2028, 7, 1)})).to(eq(["Food", "New car"]))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "combines with exclude_once" do
|
|
90
|
+
expect(descriptions({from: Date.new(2027, 9, 1), exclude_once: true})).to(eq(["Food", "Insurance"]))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/spec/tags_spec.rb
CHANGED
|
@@ -18,9 +18,15 @@ CSV
|
|
|
18
18
|
RSpec.describe "tags" do
|
|
19
19
|
it "outputs hledger tags in posting comments" do
|
|
20
20
|
expected = <<~JOURNAL
|
|
21
|
-
~ monthly from 2023-03-01 * Salary
|
|
21
|
+
~ monthly from 2023-03-01 * Salary
|
|
22
22
|
Income:Salary £-3,500.00; fixed:, essential:
|
|
23
|
+
Assets:Bank
|
|
24
|
+
|
|
25
|
+
~ monthly from 2023-03-01 * Food
|
|
23
26
|
Expenses:Food £500.00 ; living:, essential:
|
|
27
|
+
Assets:Bank
|
|
28
|
+
|
|
29
|
+
~ monthly from 2023-03-01 * Netflix
|
|
24
30
|
Expenses:Subscriptions £15.00 ; living:
|
|
25
31
|
Assets:Bank
|
|
26
32
|
|
|
@@ -31,8 +37,11 @@ RSpec.describe "tags" do
|
|
|
31
37
|
|
|
32
38
|
it "omits comments when no tags are present" do
|
|
33
39
|
expected = <<~JOURNAL
|
|
34
|
-
~ monthly from 2023-03-01 * Salary
|
|
40
|
+
~ monthly from 2023-03-01 * Salary
|
|
35
41
|
Income:Salary £-3,500.00
|
|
42
|
+
Assets:Bank
|
|
43
|
+
|
|
44
|
+
~ monthly from 2023-03-01 * Food
|
|
36
45
|
Expenses:Food £500.00
|
|
37
46
|
Assets:Bank
|
|
38
47
|
|
|
@@ -43,8 +52,11 @@ RSpec.describe "tags" do
|
|
|
43
52
|
|
|
44
53
|
it "filters transactions by a single tag" do
|
|
45
54
|
expected = <<~JOURNAL
|
|
46
|
-
~ monthly from 2023-03-01 * Food
|
|
55
|
+
~ monthly from 2023-03-01 * Food
|
|
47
56
|
Expenses:Food £500.00; living:, essential:
|
|
57
|
+
Assets:Bank
|
|
58
|
+
|
|
59
|
+
~ monthly from 2023-03-01 * Netflix
|
|
48
60
|
Expenses:Subscriptions £15.00 ; living:
|
|
49
61
|
Assets:Bank
|
|
50
62
|
|
|
@@ -55,8 +67,11 @@ RSpec.describe "tags" do
|
|
|
55
67
|
|
|
56
68
|
it "filters transactions by multiple tags (OR logic)" do
|
|
57
69
|
expected = <<~JOURNAL
|
|
58
|
-
~ monthly from 2023-03-01 * Salary
|
|
70
|
+
~ monthly from 2023-03-01 * Salary
|
|
59
71
|
Income:Salary £-3,500.00; fixed:, essential:
|
|
72
|
+
Assets:Bank
|
|
73
|
+
|
|
74
|
+
~ monthly from 2023-03-01 * Food
|
|
60
75
|
Expenses:Food £500.00 ; living:, essential:
|
|
61
76
|
Assets:Bank
|
|
62
77
|
|
|
@@ -81,8 +96,11 @@ RSpec.describe "tags" do
|
|
|
81
96
|
|
|
82
97
|
it "excludes tags in generator output" do
|
|
83
98
|
expected = <<~JOURNAL
|
|
84
|
-
~ monthly from 2023-03-01 * Food
|
|
99
|
+
~ monthly from 2023-03-01 * Food
|
|
85
100
|
Expenses:Food £500.00; living:, essential:
|
|
101
|
+
Assets:Bank
|
|
102
|
+
|
|
103
|
+
~ monthly from 2023-03-01 * Netflix
|
|
86
104
|
Expenses:Subscriptions £15.00 ; living:
|
|
87
105
|
Assets:Bank
|
|
88
106
|
|
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: 3.
|
|
4
|
+
version: 3.4.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: 2026-
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: abbrev
|
|
@@ -132,10 +132,13 @@ extensions: []
|
|
|
132
132
|
extra_rdoc_files: []
|
|
133
133
|
files:
|
|
134
134
|
- ".github/workflows/ci.yml"
|
|
135
|
+
- ".github/workflows/manually_publish.yml"
|
|
135
136
|
- ".github/workflows/release.yml"
|
|
136
137
|
- ".gitignore"
|
|
137
138
|
- ".mise.toml"
|
|
139
|
+
- ".release-please-manifest.json"
|
|
138
140
|
- ".rubocop.yml"
|
|
141
|
+
- AGENTS.md
|
|
139
142
|
- CHANGELOG.md
|
|
140
143
|
- Gemfile
|
|
141
144
|
- LICENSE
|
|
@@ -154,10 +157,12 @@ files:
|
|
|
154
157
|
- lib/hledger_forecast/generator.rb
|
|
155
158
|
- lib/hledger_forecast/settings.rb
|
|
156
159
|
- lib/hledger_forecast/summarizer.rb
|
|
160
|
+
- lib/hledger_forecast/summarizer_exporter.rb
|
|
157
161
|
- lib/hledger_forecast/summarizer_formatter.rb
|
|
158
162
|
- lib/hledger_forecast/transaction.rb
|
|
159
163
|
- lib/hledger_forecast/transactions/default.rb
|
|
160
164
|
- lib/hledger_forecast/version.rb
|
|
165
|
+
- release-please-config.json
|
|
161
166
|
- spec/calculator_spec.rb
|
|
162
167
|
- spec/cli_spec.rb
|
|
163
168
|
- spec/compare_spec.rb
|
|
@@ -177,6 +182,7 @@ files:
|
|
|
177
182
|
- spec/stubs/transactions_found.journal
|
|
178
183
|
- spec/stubs/transactions_found_inverse.journal
|
|
179
184
|
- spec/stubs/transactions_not_found.journal
|
|
185
|
+
- spec/summarizer_exporter_spec.rb
|
|
180
186
|
- spec/summarizer_spec.rb
|
|
181
187
|
- spec/tags_spec.rb
|
|
182
188
|
- spec/verbose_output_spec.rb
|
|
@@ -224,6 +230,7 @@ test_files:
|
|
|
224
230
|
- spec/stubs/transactions_found.journal
|
|
225
231
|
- spec/stubs/transactions_found_inverse.journal
|
|
226
232
|
- spec/stubs/transactions_not_found.journal
|
|
233
|
+
- spec/summarizer_exporter_spec.rb
|
|
227
234
|
- spec/summarizer_spec.rb
|
|
228
235
|
- spec/tags_spec.rb
|
|
229
236
|
- spec/verbose_output_spec.rb
|