rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
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