rvgp 0.3.2
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 +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
|