hledger-forecast 2.0.0 → 3.0.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/{test.yml → ci.yml} +18 -10
- data/.github/workflows/publish_ruby_gem.yml +24 -0
- data/.github/workflows/release.yml +12 -13
- data/.mise.toml +2 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +1 -0
- data/README.md +149 -119
- data/example.csv +15 -15
- data/example.journal +17 -18
- data/hledger-forecast.gemspec +20 -18
- data/lib/hledger_forecast/calculator.rb +7 -15
- data/lib/hledger_forecast/cli.rb +98 -71
- data/lib/hledger_forecast/comparator.rb +12 -11
- data/lib/hledger_forecast/forecast.rb +29 -0
- data/lib/hledger_forecast/formatter.rb +13 -15
- data/lib/hledger_forecast/generator.rb +32 -72
- data/lib/hledger_forecast/settings.rb +34 -47
- data/lib/hledger_forecast/summarizer.rb +34 -55
- data/lib/hledger_forecast/summarizer_formatter.rb +75 -78
- data/lib/hledger_forecast/transaction.rb +63 -0
- data/lib/hledger_forecast/transactions/default.rb +45 -72
- data/lib/hledger_forecast/version.rb +1 -1
- data/lib/hledger_forecast.rb +21 -22
- data/spec/calculator_spec.rb +45 -0
- data/spec/cli_spec.rb +19 -17
- data/spec/compare_spec.rb +16 -14
- data/spec/computed_amounts_spec.rb +7 -7
- data/spec/custom_spec.rb +9 -9
- data/spec/formatter_spec.rb +51 -0
- data/spec/half-yearly_spec.rb +5 -5
- data/spec/monthly_end_date_spec.rb +6 -6
- data/spec/monthly_end_date_transaction_spec.rb +10 -10
- data/spec/monthly_spec.rb +7 -7
- data/spec/once_spec.rb +5 -5
- data/spec/quarterly_spec.rb +5 -5
- data/spec/settings_spec.rb +101 -0
- data/spec/stubs/forecast.csv +4 -4
- data/spec/summarizer_spec.rb +28 -33
- data/spec/tags_spec.rb +92 -0
- data/spec/verbose_output_spec.rb +8 -8
- data/spec/yearly_spec.rb +5 -5
- metadata +49 -13
- data/lib/hledger_forecast/transactions/modifiers.rb +0 -90
- data/lib/hledger_forecast/transactions/trackers.rb +0 -88
- data/lib/hledger_forecast/utilities.rb +0 -14
- data/spec/track_spec.rb +0 -105
|
@@ -1,56 +1,43 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
# Set the options from a user's config
|
|
3
2
|
class Settings
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if row['type'] == 'settings'
|
|
24
|
-
|
|
25
|
-
settings[:currency] = if row['frequency'] == "currency"
|
|
26
|
-
row['account']
|
|
27
|
-
else
|
|
28
|
-
"USD"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
settings[:show_symbol] = if row['frequency'] == "show_symbol"
|
|
32
|
-
row['account']
|
|
33
|
-
else
|
|
34
|
-
true
|
|
35
|
-
end
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
currency: "USD",
|
|
5
|
+
show_symbol: true,
|
|
6
|
+
sign_before_symbol: false,
|
|
7
|
+
thousands_separator: ","
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader(
|
|
11
|
+
:currency,
|
|
12
|
+
:show_symbol,
|
|
13
|
+
:sign_before_symbol,
|
|
14
|
+
:thousands_separator,
|
|
15
|
+
:verbose,
|
|
16
|
+
:roll_up
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.parse(settings_rows, cli_options = nil)
|
|
20
|
+
new(settings_rows, cli_options)
|
|
21
|
+
end
|
|
36
22
|
|
|
37
|
-
|
|
38
|
-
row['account']
|
|
39
|
-
else
|
|
40
|
-
false
|
|
41
|
-
end
|
|
23
|
+
def verbose? = @verbose
|
|
42
24
|
|
|
43
|
-
|
|
44
|
-
row['account']
|
|
45
|
-
else
|
|
46
|
-
","
|
|
47
|
-
end
|
|
48
|
-
end
|
|
25
|
+
private
|
|
49
26
|
|
|
50
|
-
|
|
51
|
-
|
|
27
|
+
def initialize(settings_rows, cli_options)
|
|
28
|
+
overrides = settings_rows.each_with_object({}) { |row, h| h[row[:frequency]] = row[:account] }
|
|
29
|
+
opts = cli_options || {}
|
|
52
30
|
|
|
53
|
-
|
|
31
|
+
@currency = opts[:currency] || overrides["currency"] || DEFAULTS[:currency]
|
|
32
|
+
@show_symbol = opts[:show_symbol] || overrides["show_symbol"] || DEFAULTS[:show_symbol]
|
|
33
|
+
@sign_before_symbol = opts[:sign_before_symbol] ||
|
|
34
|
+
overrides["sign_before_symbol"] ||
|
|
35
|
+
DEFAULTS[:sign_before_symbol]
|
|
36
|
+
@thousands_separator = opts[:thousands_separator] ||
|
|
37
|
+
overrides["thousands_separator"] ||
|
|
38
|
+
DEFAULTS[:thousands_separator]
|
|
39
|
+
@verbose = opts[:verbose] || false
|
|
40
|
+
@roll_up = opts[:roll_up]
|
|
54
41
|
end
|
|
55
42
|
end
|
|
56
43
|
end
|
|
@@ -1,72 +1,51 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
# Summarise a forecast yaml file and output it to the CLI
|
|
3
2
|
class Summarizer
|
|
4
|
-
def self.summarize(
|
|
5
|
-
new.summarize(
|
|
3
|
+
def self.summarize(csv_string, cli_options = nil)
|
|
4
|
+
new.summarize(csv_string, cli_options)
|
|
6
5
|
end
|
|
7
6
|
|
|
8
|
-
def summarize(
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
def summarize(csv_string, cli_options = nil)
|
|
8
|
+
forecast = Forecast.parse(csv_string, cli_options)
|
|
9
|
+
transactions = forecast.transactions.reject(&:summary_exclude?)
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def generate(forecast)
|
|
18
|
-
output = []
|
|
11
|
+
if cli_options&.dig(:tags)
|
|
12
|
+
raise "The --tags option requires a 'tag' column in the forecast CSV" unless forecast.has_tags_column?
|
|
13
|
+
transactions = transactions.select { |t| t.matches_tags?(cli_options[:tags]) }
|
|
14
|
+
end
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
next if row['summary_exclude']
|
|
16
|
+
output = transactions.map { |t| build_summary_row(t) }
|
|
17
|
+
output = apply_roll_up(output, forecast.settings.roll_up) if forecast.settings.roll_up
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
{output: output, settings: forecast.settings}
|
|
20
|
+
end
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
annualised_amount = row['roll-up'] ? row['amount'] * row['roll-up'].to_f : row['amount'] * annualise(row['type'])
|
|
28
|
-
rescue StandardError
|
|
29
|
-
puts "\nError: ".bold.red + 'Could not create an annualised ammount. Have you set the roll-up for your custom type transactions?'
|
|
30
|
-
exit
|
|
31
|
-
end
|
|
22
|
+
private
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
category: row['category'],
|
|
40
|
-
description: row['description'],
|
|
41
|
-
amount: row['amount'],
|
|
42
|
-
annualised_amount: annualised_amount.to_f,
|
|
43
|
-
exclude: row['summary_exclude']
|
|
44
|
-
}
|
|
24
|
+
def build_summary_row(transaction)
|
|
25
|
+
annualised = begin
|
|
26
|
+
transaction.annualised_amount
|
|
27
|
+
rescue KeyError => e
|
|
28
|
+
puts("\nError: ".bold.red + e.message)
|
|
29
|
+
exit
|
|
45
30
|
end
|
|
46
31
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
'once' => 1,
|
|
59
|
-
'daily' => 352,
|
|
60
|
-
'weekly' => 52
|
|
32
|
+
{
|
|
33
|
+
account: transaction.account,
|
|
34
|
+
from: transaction.from,
|
|
35
|
+
to: transaction.to,
|
|
36
|
+
type: transaction.type,
|
|
37
|
+
frequency: transaction.frequency,
|
|
38
|
+
category: transaction.category,
|
|
39
|
+
description: transaction.description,
|
|
40
|
+
amount: transaction.amount,
|
|
41
|
+
annualised_amount: annualised.to_f,
|
|
42
|
+
exclude: transaction.summary_exclude
|
|
61
43
|
}
|
|
62
|
-
|
|
63
|
-
annualise[period]
|
|
64
44
|
end
|
|
65
45
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
end
|
|
46
|
+
def apply_roll_up(output, roll_up_period)
|
|
47
|
+
divisor = ANNUAL_MULTIPLIERS.fetch(roll_up_period)
|
|
48
|
+
output.each { |row| row[:rolled_up_amount] = row[:annualised_amount] / divisor }
|
|
70
49
|
end
|
|
71
50
|
end
|
|
72
51
|
end
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
|
-
# Output the summarised forecast to the CLI
|
|
3
2
|
class SummarizerFormatter
|
|
4
3
|
def self.format(output, settings)
|
|
5
4
|
new.format(output, settings)
|
|
@@ -11,7 +10,7 @@ module HledgerForecast
|
|
|
11
10
|
|
|
12
11
|
init_table
|
|
13
12
|
|
|
14
|
-
if @settings
|
|
13
|
+
if @settings.roll_up.nil?
|
|
15
14
|
add_rows_to_table(output)
|
|
16
15
|
add_total_row_to_table(output, :amount)
|
|
17
16
|
else
|
|
@@ -25,115 +24,113 @@ module HledgerForecast
|
|
|
25
24
|
private
|
|
26
25
|
|
|
27
26
|
def init_table
|
|
28
|
-
title =
|
|
29
|
-
title += " (#{@settings
|
|
27
|
+
title = "FORECAST SUMMARY"
|
|
28
|
+
title += " (#{@settings.roll_up.upcase} ROLL UP)" if @settings.roll_up
|
|
30
29
|
|
|
31
|
-
@table.add_row([{
|
|
30
|
+
@table.add_row([{value: title.bold, colspan: 3, alignment: :center}])
|
|
32
31
|
@table.add_separator
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
def add_rows_to_table(data)
|
|
36
|
-
data
|
|
35
|
+
data.group_by { |item| item[:type] }.tap { |d| sort_items(d) }.each_with_index do |(type, items), index|
|
|
36
|
+
@table.add_row([{value: type.capitalize.bold, colspan: 3, alignment: :center}])
|
|
37
37
|
|
|
38
|
-
data = sort(data)
|
|
39
|
-
|
|
40
|
-
data.each_with_index do |(type, items), index|
|
|
41
|
-
@table.add_row([{ value: type.capitalize.bold, colspan: 3, alignment: :center }])
|
|
42
38
|
total = 0
|
|
43
39
|
items.each do |item|
|
|
44
40
|
total += item[:amount].to_f
|
|
45
41
|
|
|
46
|
-
if @settings
|
|
47
|
-
@table.add_row
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
if @settings.verbose
|
|
43
|
+
@table.add_row(
|
|
44
|
+
[
|
|
45
|
+
{value: item[:category], alignment: :left},
|
|
46
|
+
{value: item[:description], alignment: :left},
|
|
47
|
+
{value: format_amount(item[:amount]), alignment: :right}
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
50
|
else
|
|
51
|
-
@table.add_row
|
|
52
|
-
|
|
51
|
+
@table.add_row(
|
|
52
|
+
[
|
|
53
|
+
{value: item[:category], colspan: 2, alignment: :left},
|
|
54
|
+
{value: format_amount(item[:amount]), alignment: :right}
|
|
55
|
+
]
|
|
56
|
+
)
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
|
|
56
|
-
@table.add_row
|
|
57
|
-
|
|
60
|
+
@table.add_row(
|
|
61
|
+
[
|
|
62
|
+
{value: "TOTAL".bold, colspan: 2, alignment: :left},
|
|
63
|
+
{value: format_amount(total).bold, alignment: :right}
|
|
64
|
+
]
|
|
65
|
+
)
|
|
58
66
|
|
|
59
67
|
@table.add_separator if index != data.size - 1
|
|
60
68
|
end
|
|
61
69
|
end
|
|
62
70
|
|
|
63
|
-
def sort(data)
|
|
64
|
-
data.each do |type, items|
|
|
65
|
-
data[type] = items.sort_by do |item|
|
|
66
|
-
value = item[:amount].to_f
|
|
67
|
-
[value >= 0 ? 1 : 0, value >= 0 ? -value : value]
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
71
|
def add_rolled_up_rows_to_table(data)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
sum_hash[item[:category]][:sum] += item[:rolled_up_amount]
|
|
77
|
-
sum_hash[item[:category]][:descriptions] << item[:description]
|
|
72
|
+
aggregated = data.each_with_object(Hash.new { |h, k| h[k] = {sum: 0, descriptions: []} }) do |item, h|
|
|
73
|
+
h[item[:category]][:sum] += item[:rolled_up_amount]
|
|
74
|
+
h[item[:category]][:descriptions] << item[:description]
|
|
78
75
|
end
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
77
|
+
aggregated.each_value { |v| v[:descriptions] = v[:descriptions].join(", ") }
|
|
78
|
+
|
|
79
|
+
sort_by_amount(aggregated.map { |cat, v| {category: cat, sum: v[:sum], descriptions: v[:descriptions]} })
|
|
80
|
+
.each do |hash|
|
|
81
|
+
if @settings.verbose
|
|
82
|
+
@table.add_row(
|
|
83
|
+
[
|
|
84
|
+
{value: hash[:category], colspan: 1, alignment: :left},
|
|
85
|
+
{value: hash[:descriptions], colspan: 1, alignment: :left},
|
|
86
|
+
{value: format_amount(hash[:sum]), alignment: :right}
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
else
|
|
90
|
+
@table.add_row(
|
|
91
|
+
[
|
|
92
|
+
{value: hash[:category], colspan: 2, alignment: :left},
|
|
93
|
+
{value: format_amount(hash[:sum]), alignment: :right}
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
98
97
|
end
|
|
99
|
-
end
|
|
100
98
|
end
|
|
101
99
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
end
|
|
100
|
+
def add_total_row_to_table(data, row_to_sum)
|
|
101
|
+
total = data.sum { |item| item[row_to_sum].to_f }
|
|
102
|
+
income = data.sum { |item| (v = item[row_to_sum].to_f) < 0 ? v : 0 }
|
|
103
|
+
savings = (total / income * 100).round(2)
|
|
107
104
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
@table.add_separator
|
|
106
|
+
@table.add_row(
|
|
107
|
+
[
|
|
108
|
+
{value: "TOTAL".bold, colspan: 2, alignment: :left},
|
|
109
|
+
{value: format_amount(total).bold, alignment: :right}
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
@table.add_row(
|
|
113
|
+
[
|
|
114
|
+
{value: "as a % of income".italic, colspan: 2, alignment: :left},
|
|
115
|
+
{value: "#{savings}%".italic, alignment: :right}
|
|
116
|
+
]
|
|
117
|
+
)
|
|
113
118
|
end
|
|
114
119
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
end
|
|
120
|
+
def sort_items(grouped)
|
|
121
|
+
grouped.transform_values! { |items| sort_by_amount(items, key: :amount) }
|
|
122
|
+
end
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
def sort_by_amount(collection, key: :sum)
|
|
125
|
+
collection.sort_by do |item|
|
|
126
|
+
value = item[key].to_f
|
|
127
|
+
[value >= 0 ? 1 : 0, value >= 0 ? -value : value]
|
|
123
128
|
end
|
|
124
|
-
|
|
125
|
-
savings = (total / income * 100).to_f.round(2)
|
|
126
|
-
|
|
127
|
-
@table.add_separator
|
|
128
|
-
@table.add_row [{ value: "TOTAL".bold, colspan: 2, alignment: :left },
|
|
129
|
-
{ value: format_amount(total).bold, alignment: :right }]
|
|
130
|
-
@table.add_row [{ value: "as a % of income".italic, colspan: 2, alignment: :left },
|
|
131
|
-
{ value: "#{savings}%".italic, alignment: :right }]
|
|
132
129
|
end
|
|
133
130
|
|
|
134
131
|
def format_amount(amount)
|
|
135
|
-
|
|
136
|
-
amount.to_f
|
|
132
|
+
formatted = Formatter.format_money(amount, @settings)
|
|
133
|
+
amount.to_f.negative? ? formatted.green : formatted.red
|
|
137
134
|
end
|
|
138
135
|
end
|
|
139
136
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module HledgerForecast
|
|
2
|
+
ANNUAL_MULTIPLIERS = {
|
|
3
|
+
"monthly" => 12,
|
|
4
|
+
"quarterly" => 4,
|
|
5
|
+
"half-yearly" => 2,
|
|
6
|
+
"yearly" => 1,
|
|
7
|
+
"once" => 1,
|
|
8
|
+
"daily" => 352,
|
|
9
|
+
"weekly" => 52
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
Transaction = Struct.new(
|
|
13
|
+
:type,
|
|
14
|
+
:frequency,
|
|
15
|
+
:account,
|
|
16
|
+
:from,
|
|
17
|
+
:to,
|
|
18
|
+
:description,
|
|
19
|
+
:category,
|
|
20
|
+
:amount,
|
|
21
|
+
:roll_up,
|
|
22
|
+
:summary_exclude,
|
|
23
|
+
:tags,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
) do
|
|
26
|
+
def self.from_row(row)
|
|
27
|
+
from = Date.parse(row[:from].to_s)
|
|
28
|
+
new(
|
|
29
|
+
type: row[:type],
|
|
30
|
+
frequency: row[:frequency],
|
|
31
|
+
account: row[:account],
|
|
32
|
+
from: from,
|
|
33
|
+
to: row[:to] ? Calculator.evaluate_date(from, row[:to].to_s) : nil,
|
|
34
|
+
description: row[:description],
|
|
35
|
+
category: row[:category],
|
|
36
|
+
amount: Calculator.evaluate(row[:amount]),
|
|
37
|
+
roll_up: row[:roll_up],
|
|
38
|
+
summary_exclude: row[:summary_exclude],
|
|
39
|
+
tags: row[:tag].to_s.split("|").map(&:strip).reject(&:empty?)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def matches_tags?(filter_tags)
|
|
44
|
+
return true if filter_tags.nil? || filter_tags.empty?
|
|
45
|
+
(tags & filter_tags).any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def annualised_amount
|
|
49
|
+
if roll_up
|
|
50
|
+
amount * roll_up
|
|
51
|
+
else
|
|
52
|
+
amount *
|
|
53
|
+
ANNUAL_MULTIPLIERS.fetch(type) {
|
|
54
|
+
raise(KeyError, "Unknown type '#{type}'. Set a roll-up for custom transactions.")
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def summary_exclude? = !!summary_exclude
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
TransactionGroup = Struct.new(:type, :frequency, :account, :from, :to, :transactions, keyword_init: true)
|
|
63
|
+
end
|
|
@@ -1,100 +1,73 @@
|
|
|
1
1
|
module HledgerForecast
|
|
2
2
|
module Transactions
|
|
3
|
-
# Generate
|
|
3
|
+
# Generate hledger periodic transactions from TransactionGroups.
|
|
4
4
|
# Example output:
|
|
5
|
-
# ~ monthly from 2023-05-
|
|
5
|
+
# ~ monthly from 2023-05-01 * Food expenses
|
|
6
6
|
# Expenses:Groceries $250.00 ; Food expenses
|
|
7
7
|
# Assets:Checking
|
|
8
8
|
class Default
|
|
9
|
-
def self.
|
|
10
|
-
new(
|
|
9
|
+
def self.render(groups, settings)
|
|
10
|
+
new(groups, settings).render
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
next if row[:type] == "settings"
|
|
16
|
-
|
|
17
|
-
process_transactions(row)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
output
|
|
13
|
+
def render
|
|
14
|
+
groups.map { |group| render_group(group) }.join.gsub(/\n{2,}/, "\n\n")
|
|
21
15
|
end
|
|
22
16
|
|
|
23
17
|
private
|
|
24
18
|
|
|
25
|
-
attr_reader :
|
|
19
|
+
attr_reader :groups, :settings
|
|
26
20
|
|
|
27
|
-
def initialize(
|
|
28
|
-
@
|
|
21
|
+
def initialize(groups, settings)
|
|
22
|
+
@groups = groups
|
|
29
23
|
@settings = settings
|
|
30
|
-
|
|
24
|
+
precompute_padding
|
|
31
25
|
end
|
|
32
26
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if @settings[:verbose]
|
|
38
|
-
description = row[:description]
|
|
39
|
-
transactions = [row]
|
|
40
|
-
else
|
|
41
|
-
description = get_descriptions(row[:transactions])
|
|
42
|
-
transactions = row[:transactions]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
header = build_header(row, frequency, to, description)
|
|
46
|
-
footer = build_footer(row)
|
|
27
|
+
def precompute_padding
|
|
28
|
+
all_transactions = groups.flat_map(&:transactions)
|
|
29
|
+
formatted_amounts = all_transactions.map { |t| Formatter.format_money(t.amount, settings) }
|
|
47
30
|
|
|
48
|
-
|
|
31
|
+
@max_amount = formatted_amounts.map(&:length).max || 0
|
|
32
|
+
@max_category = all_transactions.map { |t| t.category.to_s.length }.max || 0
|
|
49
33
|
end
|
|
50
34
|
|
|
51
|
-
def
|
|
52
|
-
|
|
35
|
+
def render_group(group)
|
|
36
|
+
render_header(group) + render_postings(group) + " #{group.account}\n\n"
|
|
53
37
|
end
|
|
54
38
|
|
|
55
|
-
def
|
|
56
|
-
"
|
|
39
|
+
def render_header(group)
|
|
40
|
+
to_part = " to #{group.to}" if group.to
|
|
41
|
+
descriptions = group.transactions.map(&:description).join(", ")
|
|
42
|
+
"#{periodic_rule_for(group.type, group.frequency)} #{group.from}#{to_part} * #{descriptions}\n"
|
|
57
43
|
end
|
|
58
44
|
|
|
59
|
-
def
|
|
60
|
-
|
|
45
|
+
def render_postings(group)
|
|
46
|
+
group
|
|
47
|
+
.transactions
|
|
48
|
+
.map do |t|
|
|
49
|
+
amount = Formatter.format_money(t.amount, settings)
|
|
50
|
+
category = t.category.to_s.ljust(@max_category)
|
|
51
|
+
|
|
52
|
+
if t.tags.any?
|
|
53
|
+
tags_str = t.tags.map { |tag| "#{tag}:" }.join(", ")
|
|
54
|
+
" #{category} #{amount.ljust(@max_amount)}; #{tags_str}\n"
|
|
55
|
+
else
|
|
56
|
+
" #{category} #{amount}\n"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
.join
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
t[:description]
|
|
73
|
-
end.compact.join(', ')
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def get_periodic_rules(type, frequency)
|
|
77
|
-
map = {
|
|
78
|
-
'once' => '~',
|
|
79
|
-
'monthly' => '~ monthly from',
|
|
80
|
-
'quarterly' => '~ every 3 months from',
|
|
81
|
-
'half-yearly' => '~ every 6 months from',
|
|
82
|
-
'yearly' => '~ yearly from',
|
|
83
|
-
'custom' => "~ #{frequency} from"
|
|
84
|
-
}
|
|
85
|
-
map[type]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def write_transactions(transactions)
|
|
89
|
-
transactions.map do |t|
|
|
90
|
-
# Skip transactions that have been marked as tracked
|
|
91
|
-
next if t[:track]
|
|
92
|
-
|
|
93
|
-
t[:amount] = t[:amount].to_s.ljust(@settings[:max_amount] + 5)
|
|
94
|
-
t[:category] = t[:category].to_s.ljust(@settings[:max_category])
|
|
95
|
-
|
|
96
|
-
" #{t[:category]} #{t[:amount]}; #{t[:description]}\n"
|
|
97
|
-
end.compact
|
|
62
|
+
def periodic_rule_for(type, frequency)
|
|
63
|
+
{
|
|
64
|
+
"once" => "~",
|
|
65
|
+
"monthly" => "~ monthly from",
|
|
66
|
+
"quarterly" => "~ every 3 months from",
|
|
67
|
+
"half-yearly" => "~ every 6 months from",
|
|
68
|
+
"yearly" => "~ yearly from",
|
|
69
|
+
"custom" => "~ #{frequency} from"
|
|
70
|
+
}.fetch(type)
|
|
98
71
|
end
|
|
99
72
|
end
|
|
100
73
|
end
|