hledger-forecast 3.3.1 → 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 +1 -1
- data/.github/workflows/release.yml +3 -2
- data/.release-please-manifest.json +3 -0
- data/AGENTS.md +2 -0
- data/CHANGELOG.md +7 -0
- data/README.md +39 -2
- data/lib/hledger_forecast/calculator.rb +11 -0
- data/lib/hledger_forecast/cli.rb +55 -25
- 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 +33 -0
- data/spec/summarizer_exporter_spec.rb +62 -0
- data/spec/summarizer_spec.rb +43 -0
- metadata +7 -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
|
@@ -22,7 +22,8 @@ jobs:
|
|
|
22
22
|
id: release
|
|
23
23
|
uses: googleapis/release-please-action@v5
|
|
24
24
|
with:
|
|
25
|
-
|
|
25
|
+
config-file: release-please-config.json
|
|
26
|
+
manifest-file: .release-please-manifest.json
|
|
26
27
|
|
|
27
28
|
publish:
|
|
28
29
|
runs-on: ubuntu-latest
|
|
@@ -30,7 +31,7 @@ jobs:
|
|
|
30
31
|
if: needs.release.outputs.release_created == 'true'
|
|
31
32
|
|
|
32
33
|
steps:
|
|
33
|
-
- uses: actions/checkout@
|
|
34
|
+
- uses: actions/checkout@v7
|
|
34
35
|
|
|
35
36
|
- name: Set up Ruby
|
|
36
37
|
uses: ruby/setup-ruby@v1
|
data/AGENTS.md
CHANGED
|
@@ -25,3 +25,5 @@ In non-verbose mode, `Generator.build_groups` (lib/hledger_forecast/generator.rb
|
|
|
25
25
|
## version.rb
|
|
26
26
|
|
|
27
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,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [3.3.1](https://github.com/olimorris/hledger-forecast/compare/v3.3.0...v3.3.1) (2026-07-28)
|
|
4
11
|
|
|
5
12
|
|
data/README.md
CHANGED
|
@@ -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)
|
|
@@ -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
|
@@ -38,6 +38,39 @@ RSpec.describe "command" do
|
|
|
38
38
|
expect(File.read(generated_journal)).to(eq(output))
|
|
39
39
|
end
|
|
40
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
|
+
|
|
41
74
|
it "uses the CLI to compare two CSV files" do
|
|
42
75
|
expected_output = strip_ansi_codes(
|
|
43
76
|
<<~OUTPUT
|
|
@@ -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
|
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
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- ".github/workflows/release.yml"
|
|
137
137
|
- ".gitignore"
|
|
138
138
|
- ".mise.toml"
|
|
139
|
+
- ".release-please-manifest.json"
|
|
139
140
|
- ".rubocop.yml"
|
|
140
141
|
- AGENTS.md
|
|
141
142
|
- CHANGELOG.md
|
|
@@ -156,10 +157,12 @@ files:
|
|
|
156
157
|
- lib/hledger_forecast/generator.rb
|
|
157
158
|
- lib/hledger_forecast/settings.rb
|
|
158
159
|
- lib/hledger_forecast/summarizer.rb
|
|
160
|
+
- lib/hledger_forecast/summarizer_exporter.rb
|
|
159
161
|
- lib/hledger_forecast/summarizer_formatter.rb
|
|
160
162
|
- lib/hledger_forecast/transaction.rb
|
|
161
163
|
- lib/hledger_forecast/transactions/default.rb
|
|
162
164
|
- lib/hledger_forecast/version.rb
|
|
165
|
+
- release-please-config.json
|
|
163
166
|
- spec/calculator_spec.rb
|
|
164
167
|
- spec/cli_spec.rb
|
|
165
168
|
- spec/compare_spec.rb
|
|
@@ -179,6 +182,7 @@ files:
|
|
|
179
182
|
- spec/stubs/transactions_found.journal
|
|
180
183
|
- spec/stubs/transactions_found_inverse.journal
|
|
181
184
|
- spec/stubs/transactions_not_found.journal
|
|
185
|
+
- spec/summarizer_exporter_spec.rb
|
|
182
186
|
- spec/summarizer_spec.rb
|
|
183
187
|
- spec/tags_spec.rb
|
|
184
188
|
- spec/verbose_output_spec.rb
|
|
@@ -226,6 +230,7 @@ test_files:
|
|
|
226
230
|
- spec/stubs/transactions_found.journal
|
|
227
231
|
- spec/stubs/transactions_found_inverse.journal
|
|
228
232
|
- spec/stubs/transactions_not_found.journal
|
|
233
|
+
- spec/summarizer_exporter_spec.rb
|
|
229
234
|
- spec/summarizer_spec.rb
|
|
230
235
|
- spec/tags_spec.rb
|
|
231
236
|
- spec/verbose_output_spec.rb
|