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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1eb9b09e3db9215ef7d720dcc843f6bf5302e8be64bffd3f6eceeb11b12f9aef
4
- data.tar.gz: 41c0bf21e983a5962e0014ee98f65426ad1aebfda891925de98155b9b81db4f0
3
+ metadata.gz: e8f490f679ec154468837d25878ef658fd0225d73cdd30f291679475e247d00f
4
+ data.tar.gz: bcc7247a985c775b2b053c43d25f4e5269ed224ee6f396afdaa4339f6ddf4c52
5
5
  SHA512:
6
- metadata.gz: fe6a4be8caf044b76fe6d74a0a260c42811a060c4bfc0af47c836cb9d0881e898f3c50a6256879d6db647abba08f75d7ed974be0bc4c2d42531e12bdbd04f370
7
- data.tar.gz: 461a631a0ec8b6e8d72fcb99a6a688c485a5c6e1aeae27e34c435287b54cbef37a2047f89598f6455c130889af1f0ae8bf954693ab076abdb331a8b395ea77e3
6
+ metadata.gz: 0cf0966195c657ec869ecf442031c1d9a1e71dca2ebff8544efeb1fda027945a2ed3cbd280241148cfff276fc01fc08878a25f93876ba4c0e71ada81dae67b4c
7
+ data.tar.gz: 24de22a18a04df4041401495776267beeae94e5eb3e3ee95362b81216cf48e64f484d70bcad441751c63dc74d12400cdd601cbac5ed56f7958c266f43933af02
@@ -14,7 +14,7 @@ jobs:
14
14
  ruby-version: ['3.3', '3.4']
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v7
18
18
 
19
19
  - name: Update packages 📦️
20
20
  run: sudo apt-get update
@@ -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@v4
23
+ uses: googleapis/release-please-action@v5
23
24
  with:
24
- release-type: ruby
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@v4
34
+ - uses: actions/checkout@v7
33
35
 
34
36
  - name: Set up Ruby
35
37
  uses: ruby/setup-ruby@v1
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ pkg/*
19
19
  test_output.journal
20
20
  todo.md
21
21
  .vscode
22
+ /media
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "3.4.0"
3
+ }
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/olimorris/hledger-forecast/assets/9512444/5edb77e3-0ec6-4158-9b16-3978c1259879" alt="hledger-forecast" />
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
- **"Improved", you say?** 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)!**
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 unintelligent. 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:
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
- ### Summarize
110
+ ### Summarise
111
111
 
112
- As your forecast grows, it's useful to see the totals at a glance. Think of this as your income statement, rolled up to whatever period makes sense.
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?("+")
@@ -185,15 +185,39 @@ module HledgerForecast
185
185
  options[:verbose] = true
186
186
  end
187
187
 
188
- # opts.on("--from DATE",
189
- # "Include transactions that start FROM a given DATE [yyyy-mm-dd]") do |from|
190
- # options[:from] = from
191
- # end
192
- #
193
- # opts.on("--to DATE",
194
- # "Include transactions that run TO a given DATE [yyyy-mm-dd]") do |to|
195
- # options[:to] = to
196
- # end
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 = options[: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?
@@ -1,3 +1,3 @@
1
1
  module HledgerForecast
2
- VERSION = "3.3.0"
2
+ VERSION = "3.4.0"
3
3
  end
@@ -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
+ }
@@ -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, Food
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, Monthly food shop, Random food
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, Food
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, Food, Savings
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
@@ -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, Food, Netflix
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, Food
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, Netflix
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, Food
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, Netflix
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.3.0
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-04-13 00:00:00.000000000 Z
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