rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
@@ -0,0 +1,308 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'shellwords'
|
4
|
+
require 'open3'
|
5
|
+
require 'nokogiri'
|
6
|
+
|
7
|
+
require_relative '../pta'
|
8
|
+
require_relative '../journal/pricer'
|
9
|
+
|
10
|
+
module RVGP
|
11
|
+
class Pta
|
12
|
+
# A plain text accounting adapter implementation, for the 'ledger' pta command.
|
13
|
+
# This class conforms the ledger query, and output, interfaces in a ruby-like
|
14
|
+
# syntax, and with structured ruby objects as outputs.
|
15
|
+
#
|
16
|
+
# For a more detailed example of these queries in action, take a look at the
|
17
|
+
# {https://github.com/brighton36/rra/blob/main/test/test_pta_adapter.rb test/test_pta_adapter.rb}
|
18
|
+
class Ledger < RVGP::Pta
|
19
|
+
# @!visibility private
|
20
|
+
BIN_PATH = '/usr/bin/ledger'
|
21
|
+
|
22
|
+
# This module contains intermediary parsing objects, used to represent the output of ledger in
|
23
|
+
# a structured and hierarchial format.
|
24
|
+
module Output
|
25
|
+
# This is a base class from which RVGP::Pta::Ledger's outputs inherit. It mostly just provides
|
26
|
+
# helpers for dealing with the xml output that ledger produces.
|
27
|
+
# @attr_reader [Nokogiri::XML] doc The document that was produced by ledger, to construct this object
|
28
|
+
# @attr_reader [Array<RVGP::Pta::Ledger::Output::XmlBase::Commodity>] commodities The exchange rates that
|
29
|
+
# were reportedly encountered, in the output of ledger.
|
30
|
+
# @attr_reader [RVGP::Journal::Pricer] pricer A price exchanger, to use for any currency exchange lookups
|
31
|
+
class XmlBase
|
32
|
+
# A commodity, as defined by ledger's xml meta-output. This is largely for the purpose of
|
33
|
+
# tracking exchange rates that were automatically deduced by ledger during the parsing
|
34
|
+
# of a journal. And, which, are stored in the {RVGP::Pta:::Ledger::Output::XmlBase#commodities}
|
35
|
+
# collection. hledger does not have this feature.
|
36
|
+
#
|
37
|
+
# @attr_reader [String] symbol A three letter currency code
|
38
|
+
# @attr_reader [Time] date The datetime when this commodity was declared or deduced
|
39
|
+
# @attr_reader [RVGP::Journal::Commodity] price The exchange price
|
40
|
+
class Commodity < RVGP::Base::Reader
|
41
|
+
readers :symbol, :date, :price
|
42
|
+
end
|
43
|
+
|
44
|
+
attr_reader :doc, :commodities, :pricer
|
45
|
+
|
46
|
+
# Declare the class, and initialize with the relevant options
|
47
|
+
# @param [String] xml The xml document this object is composed from
|
48
|
+
# @param [Hash] options Additional options
|
49
|
+
# @option options [RVGP::Journal::Pricer] :pricer see {RVGP::Pta::Ledger::Output::XmlBase#pricer}
|
50
|
+
def initialize(xml, options)
|
51
|
+
@doc = Nokogiri::XML xml, &:noblanks
|
52
|
+
@pricer = options[:pricer] || RVGP::Journal::Pricer.new
|
53
|
+
|
54
|
+
@commodities = doc.search('//commodities//commodity[annotation]').collect do |xcommodity|
|
55
|
+
next unless ['symbol', 'date', 'price', 'price/commodity/symbol',
|
56
|
+
'price/quantity'].all? { |path| xcommodity.at(path) }
|
57
|
+
|
58
|
+
symbol = xcommodity.at('symbol').content
|
59
|
+
date = Date.strptime(xcommodity.at('date').content, '%Y/%m/%d')
|
60
|
+
commodity = RVGP::Journal::Commodity.from_symbol_and_amount(
|
61
|
+
xcommodity.at('price/commodity/symbol').content,
|
62
|
+
xcommodity.at('price/quantity').content
|
63
|
+
)
|
64
|
+
|
65
|
+
@pricer.add date.to_time, symbol, commodity
|
66
|
+
Commodity.new symbol, date, commodity
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# An xml parser, to structure the output of balance queries to ledger. This object exists, as
|
72
|
+
# a return value, from the {RVGP::Pta::Ledger#balance} method
|
73
|
+
# @attr_reader [RVGP::Pta::BalanceAccount] accounts The accounts, and their components, that were
|
74
|
+
# returned by ledger.
|
75
|
+
class Balance < XmlBase
|
76
|
+
attr_reader :accounts
|
77
|
+
|
78
|
+
# Declare the registry, and initialize with the relevant options
|
79
|
+
# @param [String] xml see {RVGP::Pta::Ledger::Output::XmlBase#initialize}
|
80
|
+
# @param [Hash] options see {RVGP::Pta::Ledger::Output::XmlBase#initialize}
|
81
|
+
def initialize(xml, options = {})
|
82
|
+
super xml, options
|
83
|
+
|
84
|
+
# NOTE: Our output matches the --flat output, of ledger. We mostly do this
|
85
|
+
# because hledger defaults to the same output. It might cause some
|
86
|
+
# expectations to fail though, if you're comparing our balance return,
|
87
|
+
# to the cli output of balance
|
88
|
+
#
|
89
|
+
# Bear in mind that this query is slightly odd, in that account is nested
|
90
|
+
# So, I stipulate that we are at the end of a nest "not account" and
|
91
|
+
# have children "*"
|
92
|
+
xaccounts = doc.xpath('//accounts//account[not(account[*]) and *]')
|
93
|
+
|
94
|
+
if xaccounts
|
95
|
+
@accounts = xaccounts.collect do |xaccount|
|
96
|
+
fullname = xaccount.at('fullname')&.content
|
97
|
+
|
98
|
+
next unless fullname
|
99
|
+
|
100
|
+
RVGP::Pta::BalanceAccount.new(
|
101
|
+
fullname,
|
102
|
+
xaccount.xpath('account-amount/amount|account-amount/*/amount').collect do |amount|
|
103
|
+
commodity = RVGP::Journal::Commodity.from_symbol_and_amount(
|
104
|
+
amount.at('symbol').content, amount.at('quantity').content
|
105
|
+
)
|
106
|
+
commodity if commodity.quantity != 0
|
107
|
+
end.compact
|
108
|
+
)
|
109
|
+
end.compact
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# An xml parser, to structure the output of register queries to ledger. This object exists, as
|
115
|
+
# a return value, from the {RVGP::Pta::Ledger#register} method
|
116
|
+
# @attr_reader [RVGP::Pta::RegisterTransaction] transactions The transactions, and their components, that were
|
117
|
+
# returned by ledger.
|
118
|
+
class Register < XmlBase
|
119
|
+
attr_reader :transactions
|
120
|
+
|
121
|
+
# Declare the registry, and initialize with the relevant options
|
122
|
+
# @param [String] xml see {RVGP::Pta::Ledger::Output::XmlBase#initialize}
|
123
|
+
# @param [Hash] options see {RVGP::Pta::Ledger::Output::XmlBase#initialize}
|
124
|
+
def initialize(xml, options = {})
|
125
|
+
super xml, options
|
126
|
+
|
127
|
+
@transactions = doc.xpath('//transactions/transaction').collect do |xt|
|
128
|
+
date = Date.strptime(xt.at('date').content, '%Y/%m/%d')
|
129
|
+
|
130
|
+
RVGP::Pta::RegisterTransaction.new(
|
131
|
+
date,
|
132
|
+
xt.at('payee').content,
|
133
|
+
xt.xpath('postings/posting').collect do |xp|
|
134
|
+
amounts, totals = *%w[post-amount total].collect do |attr|
|
135
|
+
xp.at(attr).search('amount').collect do |xa|
|
136
|
+
RVGP::Journal::Commodity.from_symbol_and_amount(
|
137
|
+
xa.at('commodity/symbol')&.content,
|
138
|
+
xa.at('quantity').content
|
139
|
+
)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
if options[:empty] == false
|
144
|
+
amounts.reject! { |amnt| amnt.quantity.zero? }
|
145
|
+
totals.reject! { |amnt| amnt.quantity.zero? }
|
146
|
+
|
147
|
+
next if [amounts, totals].all?(&:empty?)
|
148
|
+
end
|
149
|
+
|
150
|
+
account = xp.at('account/name').content
|
151
|
+
|
152
|
+
# This phenomenon of '<None>' and '<total>', seems to only happen
|
153
|
+
# when the :empty parameter is passed.
|
154
|
+
if options[:translate_meta_accounts]
|
155
|
+
case account
|
156
|
+
when '<None>' then account = nil
|
157
|
+
when '<Total>' then account = :total
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
RVGP::Pta::RegisterPosting.new(
|
162
|
+
account,
|
163
|
+
amounts,
|
164
|
+
totals,
|
165
|
+
xp.search('metadata/value').to_h { |xvalue| [xvalue['key'], xvalue.content] },
|
166
|
+
pricer: pricer,
|
167
|
+
# This sets our date to the end -of the month, if this is a
|
168
|
+
# monthly query
|
169
|
+
price_date: options[:monthly] ? Date.new(date.year, date.month, -1) : date
|
170
|
+
)
|
171
|
+
end.compact
|
172
|
+
)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Return the tags that were found, given the specified journal path, and filters.
|
179
|
+
#
|
180
|
+
# The behavior between hledger and ledger are rather different here. Ledger has a slightly different
|
181
|
+
# featureset than HLedger, regarding tags. As such, while the return format is the same between implementations.
|
182
|
+
# The results for a given query won't be identical between pta implementations. Mostly, these results differ
|
183
|
+
# when a \\{values: true} option is supplied. In that case, ledger will return tags in a series of keys and
|
184
|
+
# values, separated by a colon, one per line. hledger, in that case, will only return the tag values themselves,
|
185
|
+
# without denotating their key.
|
186
|
+
#
|
187
|
+
# This method will simply parse the output of ledger, and return that.
|
188
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
189
|
+
# details
|
190
|
+
# @return [Array<String>] An array of the lines returned by ledger, split into strings. In most cases, this
|
191
|
+
# could also be described as simply 'an array of the filtered tags'.
|
192
|
+
def tags(*args)
|
193
|
+
args, opts = args_and_opts(*args)
|
194
|
+
|
195
|
+
# The first arg, is the tag whose values we want. This is how hledger does it, and
|
196
|
+
# we just copy that
|
197
|
+
for_tag = args.shift unless args.empty?
|
198
|
+
|
199
|
+
tags = command('tags', *args, opts).split("\n")
|
200
|
+
|
201
|
+
for_tag ? tags.map { |tag| ::Regexp.last_match(1) if /\A#{for_tag}: *(.*)/.match tag }.compact : tags
|
202
|
+
end
|
203
|
+
|
204
|
+
# Return the files that were encountered, when parsing the provided arguments.
|
205
|
+
# The output of this method should be identical, regardless of the Pta Adapter that resolves the request.
|
206
|
+
#
|
207
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
208
|
+
# details
|
209
|
+
# @return [Array<String>] An array of paths that were referenced when fetching data in provided arguments.
|
210
|
+
def files(*args)
|
211
|
+
args, opts = args_and_opts(*args)
|
212
|
+
|
213
|
+
# TODO: This should get its own error class...
|
214
|
+
raise StandardError, "Unexpected argument(s) : #{args.inspect}" unless args.empty?
|
215
|
+
|
216
|
+
stats(*args, opts)['Files these postings came from'].tap do |ret|
|
217
|
+
ret.unshift opts[:file] if opts.key?(:file) && !ret.include?(opts[:file])
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Returns the newest transaction, retured in set of transactions filtered with the provided arguments.
|
222
|
+
# This method is mostly a wrapper around \\{#register} with the \\{tail: 1} option passed to that method. This
|
223
|
+
# method may produce counterintutive results, if you override the sort: option.
|
224
|
+
#
|
225
|
+
# NOTE: For almost any case you think you want to use this method, {#newest_transaction_date} is probably
|
226
|
+
# more efficient. Particularly since this method has accelerated implementation in its {RVGP::Pta::Hledger}
|
227
|
+
# counterpart
|
228
|
+
#
|
229
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
230
|
+
# details.
|
231
|
+
# @return [RVGP::Pta::RegisterTransaction] The newest transaction in the set
|
232
|
+
def newest_transaction(*args)
|
233
|
+
args, opts = args_and_opts(*args)
|
234
|
+
first_transaction(*args, opts.merge(sort: 'date', tail: 1))
|
235
|
+
end
|
236
|
+
|
237
|
+
# Returns the oldest transaction, retured in set of transactions filtered with the provided arguments.
|
238
|
+
# This method is mostly a wrapper around {RVGP::Pta::Ledger#register} with the \\{head: 1} option passed to that
|
239
|
+
# method. This method may produce counterintutive results, if you override the sort: option.
|
240
|
+
#
|
241
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
242
|
+
# details.
|
243
|
+
# @return [RVGP::Pta::RegisterTransaction] The oldest transaction in the set
|
244
|
+
def oldest_transaction(*args)
|
245
|
+
args, opts = args_and_opts(*args)
|
246
|
+
first_transaction(*args, opts.merge(sort: 'date', head: 1))
|
247
|
+
end
|
248
|
+
|
249
|
+
# Returns the value of the 'Time Period' key, of the #{RVGP::Pta#stats} method. This method is a fast query to
|
250
|
+
# resolve.
|
251
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
252
|
+
# details.
|
253
|
+
# @return [Date] The date of the newest transaction found in your files.
|
254
|
+
def newest_transaction_date(*args)
|
255
|
+
Date.strptime ::Regexp.last_match(1), '%y-%b-%d' if /to ([^ ]+)/.match stats(*args)['Time period']
|
256
|
+
end
|
257
|
+
|
258
|
+
# Run the 'ledger balance' command, and return it's output.
|
259
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
260
|
+
# details.
|
261
|
+
# @return [RVGP::Pta::Ledger::Output::Balance] A parsed, hierarchial, representation of the output
|
262
|
+
def balance(*args)
|
263
|
+
args, opts = args_and_opts(*args)
|
264
|
+
|
265
|
+
RVGP::Pta::Ledger::Output::Balance.new command('xml', *args, opts)
|
266
|
+
end
|
267
|
+
|
268
|
+
# Run the 'ledger register' command, and return it's output.
|
269
|
+
#
|
270
|
+
# This method also supports the following options, for additional handling:
|
271
|
+
# - **:pricer** (RVGP::Journal::Pricer) - If provided, this option will use the specified pricer object when
|
272
|
+
# calculating exchange rates.
|
273
|
+
# - **:empty** (TrueClass, FalseClass) - If false, we'll remove any accounts and totals, that have
|
274
|
+
# quantities of zero.
|
275
|
+
# - **:translate_meta_accounts** (TrueClass, FalseClass) - If true, we'll convert accounts of name '<None>' to
|
276
|
+
# nil, and '<Total>' to :total. This is mostly to useful when trying to preserve uniform behaviors between pta
|
277
|
+
# adapters. (hledger seems to offer us nil, in cases where ledger offers us '<None>')
|
278
|
+
#
|
279
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
280
|
+
# details.
|
281
|
+
# @return [RVGP::Pta::Ledger::Output::Register] A parsed, hierarchial, representation of the output
|
282
|
+
def register(*args)
|
283
|
+
args, opts = args_and_opts(*args)
|
284
|
+
|
285
|
+
pricer = opts.delete :pricer
|
286
|
+
translate_meta_accounts = opts[:empty]
|
287
|
+
|
288
|
+
# We stipulate, by default, a date sort. Mostly because it makes sense. But, also so
|
289
|
+
# that this matches HLedger's default sort order
|
290
|
+
RVGP::Pta::Ledger::Output::Register.new command('xml', *args, { sort: 'date' }.merge(opts)),
|
291
|
+
monthly: (opts[:monthly] == true),
|
292
|
+
empty: opts[:empty],
|
293
|
+
pricer: pricer,
|
294
|
+
translate_meta_accounts: translate_meta_accounts
|
295
|
+
end
|
296
|
+
|
297
|
+
private
|
298
|
+
|
299
|
+
def first_transaction(*args)
|
300
|
+
reg = register(*args)
|
301
|
+
|
302
|
+
raise RVGP::Pta::AssertionError, 'Expected a single transaction' unless reg.transactions.length == 1
|
303
|
+
|
304
|
+
reg.transactions.first
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
data/lib/rvgp/pta.rb
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base/reader'
|
4
|
+
|
5
|
+
module RVGP
|
6
|
+
# A base class, which offers functionality to plain text accounting adapters. At the moment,
|
7
|
+
# that means either 'ledger', or 'hledger'. This class contains abstractions and code shared
|
8
|
+
# by the {RVGP::Pta::HLedger} and {RVGP::Pta::Ledger} classes.
|
9
|
+
#
|
10
|
+
# In addition, this class contains the {RVGP::Pta::AvailabilityHelper}, which can be included
|
11
|
+
# by any class, in order to offer shorthand access to this entire suite of functions.
|
12
|
+
class Pta
|
13
|
+
# This module is intended for use in classes that wish to provide #ledger, #hledger,
|
14
|
+
# and #pta methods, to its instances.
|
15
|
+
module AvailabilityHelper
|
16
|
+
# (see RVGP::Pta.ledger)
|
17
|
+
def ledger
|
18
|
+
RVGP::Pta.ledger
|
19
|
+
end
|
20
|
+
|
21
|
+
# (see RVGP::Pta.hledger)
|
22
|
+
def hledger
|
23
|
+
RVGP::Pta.hledger
|
24
|
+
end
|
25
|
+
|
26
|
+
# (see RVGP::Pta.pta)
|
27
|
+
def pta
|
28
|
+
RVGP::Pta.pta
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# This error is raised when a Sanity check fails. This should never happen.
|
33
|
+
class AssertionError < StandardError
|
34
|
+
end
|
35
|
+
|
36
|
+
# This class stores the Account details, as produced by the balance method of a pta adapter
|
37
|
+
# @attr_reader fullname [String] The name of this account
|
38
|
+
# @attr_reader amounts [Array<RVGP::Journal::Commodity>] The commodities in this account, as reported by balance
|
39
|
+
class BalanceAccount < RVGP::Base::Reader
|
40
|
+
readers :fullname, :amounts
|
41
|
+
# TODO: Implement and test the :pricer here
|
42
|
+
end
|
43
|
+
|
44
|
+
# This class stores the Transaction details, as produced by the register method of a pta adapter
|
45
|
+
# @attr_reader date [Date] The date this transaction occurred
|
46
|
+
# @attr_reader payee [String] The payee (aka description) line of this transaction
|
47
|
+
# @attr_reader postings [Array<RVGP::Pta::RegisterTransaction>] The postings in this transaction
|
48
|
+
class RegisterTransaction < RVGP::Base::Reader
|
49
|
+
readers :date, :payee, :postings
|
50
|
+
end
|
51
|
+
|
52
|
+
# A posting, as output by the 'register' command. Typically these are available as items in a
|
53
|
+
# transaction, via the {RVGP::Pta::RegisterTransaction#postings} method.
|
54
|
+
# @attr_reader account [String] The account this posting was assigned to
|
55
|
+
# @attr_reader amounts [Array<RVGP::Journal::Commodity>] The commodities that were encountered in the amount column
|
56
|
+
# @attr_reader totals [Array<RVGP::Journal::Commodity>] The commodities that were encountered in the total column
|
57
|
+
# @attr_reader tags [Hash<String,<String,TrueClass>>] A hash containing the tags that were encountered in this
|
58
|
+
# posting. Values are either the string that was encountered,
|
59
|
+
# for this tag. Or, true, if no specific string value was
|
60
|
+
# assigned
|
61
|
+
class RegisterPosting < RVGP::Base::Reader
|
62
|
+
readers :account, :amounts, :totals, :tags
|
63
|
+
|
64
|
+
# This method will return the sum of all commodities in the amount column, in the specified currency.
|
65
|
+
# @param [String] code A three digit currency code, or currency symbol, in which you want to express the amount
|
66
|
+
# @return [RVGP::Journal::Commodity] The amount column, expressed as a sum
|
67
|
+
def amount_in(code)
|
68
|
+
commodities_sum amounts, code
|
69
|
+
end
|
70
|
+
|
71
|
+
# This method will return the sum of all commodities in the total column, in the specified currency.
|
72
|
+
# @param [String] code A three digit currency code, or currency symbol, in which you want to express the total
|
73
|
+
# @return [RVGP::Journal::Commodity] The total column, expressed as a sum
|
74
|
+
def total_in(code)
|
75
|
+
commodities_sum totals, code
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
# Bear in mind that code/conversion is required, because the only reason
|
81
|
+
# we'd have multiple amounts, is if we have multiple currencies.
|
82
|
+
def commodities_sum(commodities, code)
|
83
|
+
currency = RVGP::Journal::Currency.from_code_or_symbol code
|
84
|
+
|
85
|
+
pricer = options[:pricer] || RVGP::Journal::Pricer.new
|
86
|
+
# There's a whole section on default valuation behavior here :
|
87
|
+
# https://hledger.org/hledger.html#valuation
|
88
|
+
date = options[:price_date] || Date.today
|
89
|
+
converted = commodities.map do |a|
|
90
|
+
# There are some outputs, which have no .code. And which only have
|
91
|
+
# a quantity. We don't want to raise an exception for these, if
|
92
|
+
# their quantity is zero, because that's still accumulateable.
|
93
|
+
next if a.quantity.zero?
|
94
|
+
|
95
|
+
a.alphabetic_code == currency.alphabetic_code ? a : pricer.convert(date.to_time, a, code)
|
96
|
+
rescue RVGP::Journal::Pricer::NoPriceError
|
97
|
+
# This seems to be what we want...
|
98
|
+
raise RVGP::Journal::Pricer::NoPriceError
|
99
|
+
end.compact
|
100
|
+
|
101
|
+
# The case of [].sum will return an integer 0, which, isn't quite what
|
102
|
+
# we want. At one point, we returned RVGP::Journal::Commodity.from_symbol_and_amount(code, 0).
|
103
|
+
# However, for some queries, this distorted the output to produce '$ 0.00', when we
|
104
|
+
# really expected nil. This seems to be the best return, that way the caller can just ||
|
105
|
+
# whatever they want, in the case they want to override this behavior.
|
106
|
+
converted.empty? ? nil : converted.sum
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Returns the output of the 'stats' command, parsed into key/value pairs.
|
111
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
112
|
+
# details
|
113
|
+
# @return [Hash<String, <String,Array<String>>>] The journal statistics. Values are either a string, or an array
|
114
|
+
# of strings, depending on what was output.
|
115
|
+
def stats(*args)
|
116
|
+
# Somehow, it turned out that both hledger and ledger were similar enough, that I could abstract
|
117
|
+
# this here....
|
118
|
+
args, opts = args_and_opts(*args)
|
119
|
+
# TODO: This should get its own error class...
|
120
|
+
raise StandardError, "Unexpected argument(s) : #{args.inspect}" unless args.empty?
|
121
|
+
|
122
|
+
command('stats', opts).scan(/^\n? *(?:([^:]+?)|(?:([^:]+?) *: *(.*?))) *$/).each_with_object([]) do |match, sum|
|
123
|
+
if match[0]
|
124
|
+
sum.last[1] = [sum.last[1]] unless sum.last[1].is_a?(Array)
|
125
|
+
sum.last[1] << match[0]
|
126
|
+
else
|
127
|
+
sum << [match[1], match[2].empty? ? [] : match[2]]
|
128
|
+
end
|
129
|
+
sum
|
130
|
+
end.to_h
|
131
|
+
end
|
132
|
+
|
133
|
+
# Returns the output of arguments to a pta adapter.
|
134
|
+
#
|
135
|
+
# **NOTE:** If the RVGP_LOG_COMMANDS environment variable is set. (say, to "1") this command will output diagnostic
|
136
|
+
# information to the console. This information will include the fully expanded command being run,
|
137
|
+
# alongside its execution time.
|
138
|
+
#
|
139
|
+
# While args and options are largely fed straight to the pta command, for processing, we support the following
|
140
|
+
# options, which, are removed from the arguments, and handled in this method.
|
141
|
+
# - **:from_s** (String)- If a string is provided here, it's fed to the STDIN of the pta adapter. And "-f -" is
|
142
|
+
# added to the program's arguments. This instructs the command to treat STDIN as a journal.
|
143
|
+
#
|
144
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
145
|
+
# details
|
146
|
+
# @return [String] The output of a pta executable
|
147
|
+
def command(*args)
|
148
|
+
opts = args.pop if args.last.is_a? Hash
|
149
|
+
open3_opts = {}
|
150
|
+
if opts
|
151
|
+
args += opts.map do |k, v|
|
152
|
+
if k.to_sym == :from_s
|
153
|
+
open3_opts[:stdin_data] = v
|
154
|
+
%w[-f -]
|
155
|
+
else
|
156
|
+
[format('--%s', k.to_s), v == true ? nil : v] unless v == false
|
157
|
+
end
|
158
|
+
end.flatten.compact
|
159
|
+
end
|
160
|
+
|
161
|
+
is_logging = ENV.key?('RVGP_LOG_COMMANDS') && !ENV['RVGP_LOG_COMMANDS'].empty?
|
162
|
+
|
163
|
+
cmd = ([bin_path] + args.collect { |a| Shellwords.escape a }).join(' ')
|
164
|
+
|
165
|
+
time_start = Time.now if is_logging
|
166
|
+
output, error, status = Open3.capture3 cmd, open3_opts
|
167
|
+
time_end = Time.now if is_logging
|
168
|
+
|
169
|
+
# Maybe We should send this to a RVGP.logger.trace...
|
170
|
+
if is_logging
|
171
|
+
pretty_cmd = ([bin_path] + args).join(' ')
|
172
|
+
|
173
|
+
puts format('(%.2<time>f elapsed) %<cmd>s', time: time_end - time_start, cmd: pretty_cmd)
|
174
|
+
end
|
175
|
+
|
176
|
+
unless status.success?
|
177
|
+
raise StandardError, format('%<adapter_name>s exited non-zero (%<exitstatus>d): %<msg>s',
|
178
|
+
adapter_name: adapter_name,
|
179
|
+
exitstatus: status.exitstatus,
|
180
|
+
msg: error)
|
181
|
+
end
|
182
|
+
|
183
|
+
output
|
184
|
+
end
|
185
|
+
|
186
|
+
# Given a splatted array, groups and returns arguments into an array of commands, and a hash of options. Options,
|
187
|
+
# are expected to be provided as a Hash, as the last element of the splat.
|
188
|
+
#
|
189
|
+
# Most of the public methods in a Pta adapter, have largely-undefined arguments. This is because these methods
|
190
|
+
# mostly just pass their method arguments, straight to ledger and hledger for handling. Therefore, the 'best'
|
191
|
+
# place to find documentation on what arguments are supported, is the man page for hledger/ledger. The reason
|
192
|
+
# most of the methods exist (#balance, #register, etc), in a pta adapter, are to control how the output of the
|
193
|
+
# command is parsed.
|
194
|
+
#
|
195
|
+
# Here are some examples of how arguments are sent straight to a pta command:
|
196
|
+
# - ledger.balance('Personal:Expenses', file: '/tmp/test.journal') becomes:
|
197
|
+
# /usr/bin/ledger xml Personal:Expenses --file /tmp/test.journal
|
198
|
+
# - pta.register('Income', monthly: true) becomes:
|
199
|
+
# /usr/bin/ledger xml Income --sort date --monthly
|
200
|
+
#
|
201
|
+
# That being said - there are some options that don't get passed directly to the pta command. Most of these
|
202
|
+
# options are documented below.
|
203
|
+
#
|
204
|
+
# This method also supports the following options, for additional handling:
|
205
|
+
# - **:hledger_args** - If this is a ledger adapter, this option is removed. Otherwise, the values of this Array
|
206
|
+
# will be returned in the first element of the return array.
|
207
|
+
# - **:hledger_opts** - If this is a ledger adapter, this option is removed. Otherwise, the values of this Hash will
|
208
|
+
# be merged with the second element of the return array.
|
209
|
+
# - **:ledger_args** - If this is an hledger adapter, this option is removed. Otherwise, the values of this Array
|
210
|
+
# will be returned in the first element of the return array.
|
211
|
+
# - **:ledger_opts** - If this is an hledger adapter, this option is removed. Otherwise, the values of this Hash
|
212
|
+
# will be merged with the second element of the return array.
|
213
|
+
# @return [Array<Object>] A two element array. The first element of this array is an Array<String> containing the
|
214
|
+
# string arguments that were provided to this method, and/or which should be passed directly
|
215
|
+
# to a pta shell command function. The second element of this array is a
|
216
|
+
# Hash<Symbol, Object> containing the options that were provided to this method, and which
|
217
|
+
# should be passed directly to a pta shell command function.
|
218
|
+
def args_and_opts(*args)
|
219
|
+
opts = args.last.is_a?(Hash) ? args.pop : {}
|
220
|
+
|
221
|
+
if ledger?
|
222
|
+
opts.delete :hledger_opts
|
223
|
+
opts.delete :hledger_args
|
224
|
+
|
225
|
+
args += opts.delete(:ledger_args) if opts.key? :ledger_args
|
226
|
+
opts.merge! opts.delete(:ledger_opts) if opts.key? :ledger_opts
|
227
|
+
elsif hledger?
|
228
|
+
opts.delete :ledger_opts
|
229
|
+
opts.delete :ledger_args
|
230
|
+
|
231
|
+
args += opts.delete(:hledger_args) if opts.key? :hledger_args
|
232
|
+
opts.merge! opts.delete(:hledger_opts) if opts.key? :hledger_opts
|
233
|
+
end
|
234
|
+
|
235
|
+
[args, opts]
|
236
|
+
end
|
237
|
+
|
238
|
+
# Determines whether this adapter is found in the expected path, and is executable
|
239
|
+
# @return [TrueClass, FalseClass] True if we can expect this adapter to execute
|
240
|
+
def present?
|
241
|
+
File.executable? bin_path
|
242
|
+
end
|
243
|
+
|
244
|
+
# The path to this adapter's binary
|
245
|
+
# @return [String] The path in the filesystem, where this pta bin is located
|
246
|
+
def bin_path
|
247
|
+
# Maybe we should support more than just /usr/bin...
|
248
|
+
self.class::BIN_PATH
|
249
|
+
end
|
250
|
+
|
251
|
+
# The name of this adapter, either :ledger or :hledger
|
252
|
+
# @return [Symbol] The adapter name, in a shorthand form. Downcased, and symbolized.
|
253
|
+
def adapter_name
|
254
|
+
self.class.name.split(':').last.downcase.to_sym
|
255
|
+
end
|
256
|
+
|
257
|
+
# Indicates whether or not this is a ledger pta adapter
|
258
|
+
# @return [TrueClass, FalseClass] True if this is an instance of {RVGP::Pta::Ledger}
|
259
|
+
def ledger?
|
260
|
+
adapter_name == :ledger
|
261
|
+
end
|
262
|
+
|
263
|
+
# Indicates whether or not this is a hledger pta adapter
|
264
|
+
# @return [TrueClass, FalseClass] True if this is an instance of {RVGP::Pta::HLedger}
|
265
|
+
def hledger?
|
266
|
+
adapter_name == :hledger
|
267
|
+
end
|
268
|
+
|
269
|
+
class << self
|
270
|
+
# Return a new instance of RVGP::Pta::Ledger
|
271
|
+
# @return [RVGP::Pta::Ledger]
|
272
|
+
def ledger
|
273
|
+
Ledger.new
|
274
|
+
end
|
275
|
+
|
276
|
+
# Return a new instance of RVGP::Pta::HLedger
|
277
|
+
# @return [RVGP::Pta::HLedger]
|
278
|
+
def hledger
|
279
|
+
HLedger.new
|
280
|
+
end
|
281
|
+
|
282
|
+
# Depending on what's installed and configured, a pta adapter is returned.
|
283
|
+
# The rules that govern what adapter is choosen, works like this:
|
284
|
+
# 1. If {Pta.pta_adapter=} has been set, then, this adapter will be returned.
|
285
|
+
# 2. If ledger is installed on the system, then ledger is returned
|
286
|
+
# 3. If hledger is installed on the system, then hledger is returned
|
287
|
+
# If no pta adapters are available, an error is raised.
|
288
|
+
# @return [RVGP::Pta::Ledger,RVGP::Pta::HLedger]
|
289
|
+
def pta
|
290
|
+
@pta ||= if @pta_adapter
|
291
|
+
send @pta_adapter
|
292
|
+
elsif ledger.present?
|
293
|
+
ledger
|
294
|
+
elsif hledger.present?
|
295
|
+
hledger
|
296
|
+
else
|
297
|
+
raise StandardError, 'No pta adapter specified, or detected, on system'
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# Override the default adapter, used by {RVGP::Pta.pta}.
|
302
|
+
# This can be set to one of: nil, :hledger, or :ledger.
|
303
|
+
# @param [Symbol] driver The adapter name, in a shorthand form. Downcased, and symbolized.
|
304
|
+
# @return [void]
|
305
|
+
def pta_adapter=(driver)
|
306
|
+
@pta = nil
|
307
|
+
@pta_adapter = driver.to_sym
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|