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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. 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