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,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module RVGP
6
+ class Journal
7
+ # This abstraction defines a simple commodity entry, as would be found in a pta journal.
8
+ # Such commodities can appear in the form of currency, such as '$ 1.30' or in any other
9
+ # format that hledger and ledger parse. ie '1 HOUSE'.
10
+ #
11
+ # There's a lot of additional functionality provided by this class, including math
12
+ # related helper functions.
13
+ #
14
+ # NOTE: the easiest way to create a commodity in your code, is by way of the
15
+ # provided {String#to_commodity} method. Such as: '$ 1.30'.to_commodity.
16
+ #
17
+ # Units of a commodity are stored in int's, with precision. This ensures that
18
+ # there is no potential for floating point precision errors, affecting these
19
+ # commodities.
20
+ #
21
+ # A number of constants, relating to the built-in support of various currencies, are
22
+ # available as part of RVGP, in the form of the
23
+ # {https://github.com/brighton36/rra/blob/main/resources/iso-4217-currencies.json iso-4217-currencies.json}
24
+ # file. Which, is loaded automatically during initialization.
25
+ #
26
+ # @attr_reader [String] code The code of this commodity. Which, may be the same as :alphabetic_code, or, may take
27
+ # the form of symbol. (ie '$'). This code is used to render the commodity to strings.
28
+ # @attr_reader [String] alphabetic_code The ISO-4217 'Alphabetic Code' of this commodity. This code is used for
29
+ # various non-rendering functions. (Equality testing, Conversion lookups...)
30
+ # @attr_reader [Integer] quantity The number of units, of this currency, before applying a fractional representation
31
+ # (Ie "$ 2.89" is stored in the form of :quantity 289)
32
+ # @attr_reader [Integer] precision The exponent of the characteristic, which is used to separate the mantissa
33
+ # from the significand.
34
+ class Commodity
35
+ attr_accessor :quantity, :code, :alphabetic_code, :precision
36
+
37
+ # @!visibility private
38
+ MATCH_AMOUNT = '([-]?[ ]*?[\d\,]+(?:\\.[\d]+|))'
39
+
40
+ # @!visibility private
41
+ MATCH_CODE = '(?:(?<!\\\\)\"(.+)(?<!\\\\)\"|([^ \-\d]+))'
42
+
43
+ # @!visibility private
44
+ MATCH_COMMODITY = ['\\A(?:', MATCH_CODE, '[ ]*?', MATCH_AMOUNT, '|', MATCH_AMOUNT, '[ ]*?', MATCH_CODE].freeze
45
+
46
+ # @!visibility private
47
+ MATCH_COMMODITY_WITHOUT_REMAINDER = Regexp.new((MATCH_COMMODITY + [')\\Z']).join)
48
+
49
+ # @!visibility private
50
+ MATCH_COMMODITY_WITH_REMAINDER = Regexp.new((MATCH_COMMODITY + [')(.*?)\\Z']).join)
51
+
52
+ # This appears to be a ruby limit, in float's
53
+ # @!visibility private
54
+ MAX_DECIMAL_DIGITS = 17
55
+
56
+ # This error is typically thrown when a commodity is evaluated against an rvalue that doesn't
57
+ # match the commodity of the lvalue.
58
+ class ConversionError < StandardError; end
59
+
60
+ # There are a handful of code paths that are currently unimplemented. Usually these are
61
+ # unimplemented because the interpretation of the request is ambiguous.
62
+ class UnimplementedError < StandardError; end
63
+
64
+ # Create a commodity, from the constituent parts
65
+ # @param [String] code see {Commodity#code}
66
+ # @param [String] alphabetic_code see {Commodity#alphabetic_code}
67
+ # @param [Integer] quantity see {Commodity#quantity}
68
+ # @param [Integer] precision see {Commodity#precision}
69
+ def initialize(code, alphabetic_code, quantity, precision)
70
+ @code = code
71
+ @alphabetic_code = alphabetic_code
72
+ @quantity = quantity.to_i
73
+ @precision = precision.to_i
74
+ end
75
+
76
+ # Render the :quantity, to a string. This is output without code notation, and merely
77
+ # expressed the quantity with the expected symbols (commas, periods) .
78
+ # @param [Hash] options formatting specifiers, affecting what output is produced
79
+ # @option options [Integer] precision Use the provided precision, instead of the :precision accessor
80
+ # @option options [TrueClass,FalseClass] commatize (false) Whether or not to insert commas in the output, between
81
+ # every three digits, in the characteristic
82
+ # @return [String] The formatted quantity
83
+ def quantity_as_s(options = {})
84
+ characteristic, mantissa = if options.key? :precision
85
+ round(options[:precision]).quantity_as_decimal_pair
86
+ else
87
+ quantity_as_decimal_pair
88
+ end
89
+
90
+ characteristic = characteristic.to_s
91
+ to_precision = options[:precision] || precision
92
+ mantissa = to_precision.positive? ? format("%0#{to_precision}d", mantissa) : nil
93
+
94
+ characteristic = characteristic.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse if options[:commatize]
95
+
96
+ [negative? ? '-' : nil, characteristic, mantissa ? '.' : nil, mantissa].compact.join
97
+ end
98
+
99
+ # Returns the quantity component of the commodity, as a BigDecimal
100
+ # @return [BigDecimal]
101
+ def quantity_as_bigdecimal
102
+ BigDecimal quantity_as_s
103
+ end
104
+
105
+ # This returns the characteristic and mantissa for our quantity, given our precision,
106
+ # note that we do not return the +/- signage. That information is destroyed here
107
+ # @return [Array<Integer>] A two-value array, with characteristic at [0], and fraction at [1]
108
+ def quantity_as_decimal_pair
109
+ characteristic = quantity.abs.to_i / (10**precision)
110
+ [characteristic, quantity.abs.to_i - (characteristic * (10**precision))]
111
+ end
112
+
113
+ # Render the commodity to a string, in the form it would appear in a journal. This output
114
+ # includes the commodity code, as well as a period and, optionally commas.
115
+ # @param [Hash] options formatting specifiers, affecting what output is produced
116
+ # @option options [Integer] precision Use the provided precision, instead of the :precision accessor
117
+ # @option options [TrueClass,FalseClass] commatize (false) Whether or not to insert commas in the output, between
118
+ # every three digits, in the characteristic
119
+ # @option options [TrueClass,FalseClass] no_code (false) If true, the code is omitted in the output
120
+ # @return [String] The formatted quantity
121
+ def to_s(options = {})
122
+ ret = [quantity_as_s(options)]
123
+ if code && !options[:no_code]
124
+ operand = code.count(' ').positive? ? ['"', code, '"'].join : code
125
+ code.length == 1 ? ret.unshift(operand) : ret.push(operand)
126
+ end
127
+ ret.join(' ')
128
+ end
129
+
130
+ # Returns the quantity component of the commodity, after being adjusted for :precision, as a Float. Consider
131
+ # using {#quantity_as_bigdecimal} instead.
132
+ # @return [Float]
133
+ def to_f
134
+ quantity_as_s.to_f
135
+ end
136
+
137
+ # Returns whether or not the quantity is greater than zero.
138
+ # @return [TrueClass,FalseClass] yes or no
139
+ def positive?
140
+ quantity.positive?
141
+ end
142
+
143
+ # Returns whether or not the quantity is less than zero.
144
+ # @return [TrueClass,FalseClass] yes or no
145
+ def negative?
146
+ quantity.negative?
147
+ end
148
+
149
+ # Multiply the quantity by -1. This mutates the state of self.
150
+ # @return [RVGP::Journal::Commodity] self, after the transformation is applied
151
+ def invert!
152
+ @quantity *= -1
153
+ self
154
+ end
155
+
156
+ # Returns a copy of the current Commodity, with the absolute value of quanity.
157
+ # @return [RVGP::Journal::Commodity] self, with quantity.abs applied
158
+ def abs
159
+ RVGP::Journal::Commodity.new code, alphabetic_code, quantity.abs, precision
160
+ end
161
+
162
+ # This method returns a new Commodity, with :floor applied to its :quantity.
163
+ # @param [Integer] to_digit Which digit to floor to
164
+ # @return [RVGP::Journal::Commodity] A new copy of self, with quantity :floor'd
165
+ def floor(to_digit)
166
+ round_or_floor to_digit, :floor
167
+ end
168
+
169
+ # This method returns a new Commodity, with :round applied to its :quantity.
170
+ # @param [Integer] to_digit Which digit to round to
171
+ # @return [RVGP::Journal::Commodity] A new copy of self, with quantity :rounded
172
+ def round(to_digit)
173
+ round_or_floor to_digit, :round
174
+ end
175
+
176
+ # @!method >(rvalue)
177
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
178
+ # is greater than rvalue's quantity.
179
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
180
+ # @return [TrueClass,FalseClass] Result of comparison.
181
+
182
+ # @!method <(rvalue)
183
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
184
+ # is less than rvalue's quantity.
185
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
186
+ # @return [TrueClass,FalseClass] Result of comparison.
187
+
188
+ # @!method <=>(rvalue)
189
+ # Ensure that rvalue is a commodity. Then returns an integer indicating whether self.quantity
190
+ # is (spaceship) rvalue's quantity. More specifically: -1 on <, 0 on ==, 1 on >.
191
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
192
+ # @return [Integer] Result of comparison: -1, 0, or 1.
193
+
194
+ # @!method >=(rvalue)
195
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
196
+ # is greater than or equal to rvalue's quantity.
197
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
198
+ # @return [TrueClass,FalseClass] Result of comparison.
199
+
200
+ # @!method <=(rvalue)
201
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
202
+ # is less than or equal to rvalue's quantity.
203
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
204
+ # @return [TrueClass,FalseClass] Result of comparison.
205
+
206
+ # @!method ==(rvalue)
207
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
208
+ # is equal to rvalue's quantity.
209
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
210
+ # @return [TrueClass,FalseClass] Result of comparison.
211
+
212
+ # @!method !=(rvalue)
213
+ # Ensure that rvalue is a commodity. Then return a boolean indicating whether self.quantity
214
+ # is not equal to rvalue's quantity.
215
+ # @param [RVGP::Journal::Commodity] rvalue Another commodity to compare our quantity to
216
+ # @return [TrueClass,FalseClass] Result of comparison.
217
+ %i[> < <=> >= <= == !=].each do |operation|
218
+ define_method(operation) do |rvalue|
219
+ assert_commodity rvalue
220
+
221
+ lquantity, rquantity = quantities_denominated_against rvalue
222
+
223
+ lquantity.send operation, rquantity
224
+ end
225
+ end
226
+
227
+ # @!method *(rvalue)
228
+ # If the rvalue is a commodity, assert that we share the same commodity code, and if so
229
+ # multiple our quantity by the rvalue quantity. If rvalue is numeric, multiply our quantity
230
+ # by this numeric.
231
+ # @param [RVGP::Journal::Commodity,Numeric] rvalue A multiplicand
232
+ # @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
233
+
234
+ # @!method /(rvalue)
235
+ # If the rvalue is a commodity, assert that we share the same commodity code, and if so
236
+ # divide our quantity by the rvalue quantity. If rvalue is numeric, divide our quantity
237
+ # by this numeric.
238
+ # @param [RVGP::Journal::Commodity,Numeric] rvalue A divisor
239
+ # @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
240
+ %i[* /].each do |operation|
241
+ define_method(operation) do |rvalue|
242
+ result = if rvalue.is_a? Numeric
243
+ # These mul/divs are often "Divide by half" "Multiply by X" instructions
244
+ # for which the rvalue is not, and should not be, a commodity.
245
+ quantity_as_bigdecimal.send operation, rvalue
246
+ else
247
+ assert_commodity rvalue
248
+
249
+ raise UnimplementedError
250
+ end
251
+
252
+ RVGP::Journal::Commodity.from_symbol_and_amount code, result.round(MAX_DECIMAL_DIGITS).to_s('F')
253
+ end
254
+ end
255
+
256
+ # @!method +(rvalue)
257
+ # If the rvalue is a commodity, assert that we share the same commodity code, and if so
258
+ # sum our quantity with the rvalue quantity.
259
+ # @param [RVGP::Journal::Commodity] rvalue An operand
260
+ # @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
261
+
262
+ # @!method -(rvalue)
263
+ # If the rvalue is a commodity, assert that we share the same commodity code, and if so
264
+ # subtract the rvalue quantity from our quantity.
265
+ # @param [RVGP::Journal::Commodity] rvalue An operand
266
+ # @return [RVGP::Journal::Commodity] A new Commodity, composed of self.code, and the resulting quantity
267
+ %i[+ -].each do |operation|
268
+ define_method(operation) do |rvalue|
269
+ assert_commodity rvalue
270
+
271
+ lquantity, rquantity, dprecision = quantities_denominated_against rvalue
272
+
273
+ result = lquantity.send operation, rquantity
274
+
275
+ # Adjust the dprecision. Probably there's a better way to do this, but,
276
+ # this works
277
+ our_currency = RVGP::Journal::Currency.from_code_or_symbol code
278
+
279
+ # This is a special case:
280
+ return RVGP::Journal::Commodity.new code, alphabetic_code, result, our_currency.minor_unit if result.zero?
281
+
282
+ # If we're trying to remove more digits than minor_unit, we have to adjust
283
+ # our cut
284
+ if our_currency && (dprecision > our_currency.minor_unit) && /\A.+?(0+)\Z/.match(result.to_s) &&
285
+ ::Regexp.last_match(1)
286
+ trim_length = ::Regexp.last_match(1).length
287
+ dprecision -= trim_length
288
+
289
+ if dprecision < our_currency.minor_unit
290
+ add = our_currency.minor_unit - dprecision
291
+ dprecision += add
292
+ trim_length -= add
293
+ end
294
+
295
+ result /= 10**trim_length
296
+ end
297
+
298
+ RVGP::Journal::Commodity.new code, alphabetic_code, result, dprecision
299
+ end
300
+ end
301
+
302
+ # We're mostly/only using this to support [].sum atm
303
+ def coerce(other)
304
+ super unless other.is_a? Integer
305
+
306
+ [RVGP::Journal::Commodity.new(code, alphabetic_code, other, precision), self]
307
+ end
308
+
309
+ def respond_to_missing?(name, _include_private = false)
310
+ @quantity.respond_to? name
311
+ end
312
+
313
+ # If an unhandled methods is encountered between ourselves, and another commodity,
314
+ # we dispatch that method to the quantity of self, against the quantity of the
315
+ # provided commodity.
316
+ # @overload method_missing(attr, rvalue)
317
+ # @param [Symbol] attr An unhandled method
318
+ # @param [RVGP::Journal::Commodity] rvalue The operand
319
+ # @return [RVGP::Journal::Commodity] A new Commodity object, created using our code, and the resulting quantity.
320
+ def method_missing(name, *args, &blk)
321
+ # This handles most all of the numeric methods
322
+ if @quantity.respond_to?(name) && args.length == 1 && args[0].is_a?(self.class)
323
+ assert_commodity args[0]
324
+
325
+ unless commodity.precision == precision
326
+ raise UnimplementedError, format('Unimplemented operation %s Wot do?', name.inspect)
327
+ end
328
+
329
+ RVGP::Journal::Commodity.new code, alphabetic_code, @quantity.send(name, args[0].quantity, &blk), precision
330
+ else
331
+ super
332
+ end
333
+ end
334
+
335
+ # Given a string, such as "$ 20.57", or "1 MERCEDESBENZ", Construct and return a commodity representation
336
+ # @param [String] str The commodity, as would be found in a PTA journal
337
+ # @return [RVGP::Journal::Commodity]
338
+ def self.from_s(str)
339
+ commodity_parts_from_string str
340
+ end
341
+
342
+ # @!visibility private
343
+ # This parses a commodity in the same way that from_s parses, but, returns the strin that remains after the
344
+ # commodity. Mostly this is here to keep ComplexCommodity DRY. Probably you shouldn't use this method
345
+ # @return [Array<RVGP::Journal::Commodity, String>] A two element array, containing a commodity, and the unparsed
346
+ # string, that remained after the commodity.
347
+ def self.from_s_with_remainder(str)
348
+ commodity_parts_from_string str, with_remainder: true
349
+ end
350
+
351
+ # Given a code, or symbol, and a quantity - Construct and return a commodity representation.
352
+ # @param [String] symbol The commodity code, or symbol, as would be found in a PTA journal
353
+ # @param [Integer, String] amount The commodity quantity. If this is a string, we search for periods, and
354
+ # calculate precision. If this is an int, we assume a precision based on
355
+ # the commodity code.
356
+ # @return [RVGP::Journal::Commodity]
357
+ def self.from_symbol_and_amount(symbol, amount = 0)
358
+ currency = RVGP::Journal::Currency.from_code_or_symbol symbol
359
+ precision, quantity = *precision_and_quantity_from_amount(amount)
360
+ # NOTE: Sometimes (say shares) we deal with fractions of a penny. If this
361
+ # is such a case, we preserve the larger precision
362
+ if currency && currency.minor_unit > precision
363
+ # This is a case where, say "$ 1" is passed. But, we want to store that
364
+ # as 100
365
+ quantity *= 10**(currency.minor_unit - precision)
366
+ precision = currency.minor_unit
367
+ end
368
+
369
+ new symbol, currency ? currency.alphabetic_code : symbol, quantity, precision
370
+ end
371
+
372
+ # @!visibility private
373
+ def self.commodity_parts_from_string(string, opts = {})
374
+ (opts[:with_remainder] ? MATCH_COMMODITY_WITH_REMAINDER : MATCH_COMMODITY_WITHOUT_REMAINDER).match string.to_s
375
+
376
+ code, amount = if ::Regexp.last_match(1) && !::Regexp.last_match(1).empty?
377
+ [::Regexp.last_match(1),
378
+ [::Regexp.last_match(2), ::Regexp.last_match(3)].compact.reject(&:empty?).first]
379
+ elsif ::Regexp.last_match(4) && !::Regexp.last_match(4).empty?
380
+ [[::Regexp.last_match(5),
381
+ ::Regexp.last_match(6)].compact.reject(&:empty?).first, ::Regexp.last_match(4)]
382
+ elsif ::Regexp.last_match(2) && !::Regexp.last_match(2).empty?
383
+ [::Regexp.last_match(2),
384
+ [::Regexp.last_match(3), ::Regexp.last_match(4)].compact.reject(&:empty?).first]
385
+ end
386
+
387
+ if !amount || !code || amount.empty? || code.empty?
388
+ raise UnimplementedError, format('Unimplemented Commodity::from_s. Against: %s. Wot do?', string.inspect)
389
+ end
390
+
391
+ commodity = from_symbol_and_amount code, amount.tr(',', '')
392
+
393
+ opts[:with_remainder] ? [commodity, ::Regexp.last_match(7)] : commodity
394
+ end
395
+
396
+ # @!visibility private
397
+ def self.precision_and_quantity_from_amount(amount)
398
+ [amount.to_s.reverse.index('.') || 0, amount.to_s.tr('.', '').to_i]
399
+ end
400
+
401
+ private
402
+
403
+ def round_or_floor(to_digit, function)
404
+ raise UnimplementedError unless to_digit >= 0
405
+
406
+ characteristic, mantissa = quantity_as_decimal_pair
407
+
408
+ new_characteristic = characteristic * (10**to_digit)
409
+ new_mantissa = mantissa.positive? ? (mantissa / (10**(precision - to_digit))).to_i : 0
410
+
411
+ new_quantity = new_characteristic + new_mantissa
412
+
413
+ # Round up?
414
+ if function == :round && mantissa.positive? && precision > to_digit
415
+ # We want the determinant to be the right-most digit in the round_determinant, then we mod 10 that
416
+ round_determinant = (mantissa / (10**(precision - to_digit - 1)) % 10).to_i
417
+
418
+ new_quantity += 1 if round_determinant >= 5
419
+ end
420
+
421
+ RVGP::Journal::Commodity.new code, alphabetic_code, positive? ? new_quantity : new_quantity * -1, to_digit
422
+ end
423
+
424
+ # This returns our quantity, and rvalue.quantity, after adjusting both
425
+ # to the largest of the two denominators
426
+ def quantities_denominated_against(rvalue)
427
+ lquantity = quantity
428
+ rquantity = rvalue.quantity
429
+ new_precision = precision
430
+
431
+ if precision > rvalue.precision
432
+ new_precision = precision
433
+ rquantity = rvalue.quantity * (10**(precision - rvalue.precision))
434
+ elsif precision < rvalue.precision
435
+ new_precision = rvalue.precision
436
+ lquantity = quantity * (10**(rvalue.precision - precision))
437
+ end
438
+
439
+ [lquantity, rquantity, new_precision]
440
+ end
441
+
442
+ def assert_commodity(commodity)
443
+ our_codes = [alphabetic_code, code]
444
+
445
+ unless our_codes.include?(commodity.alphabetic_code)
446
+ raise ConversionError, format('Provided commodity %<commodity>s does not match %<codes>s',
447
+ commodity: commodity.inspect,
448
+ codes: our_codes.inspect)
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ class Journal
5
+ # These 'complex currency' specifications appear to be mostly for non-register
6
+ # and non-balance reports. The ledger manual labels these 'Cost Expressions'.
7
+ # We really don't use these much, and I'm not entirely sure the parsing rules
8
+ # make sense. Some of the rules in the documentation even seem a bit
9
+ # inconsistent (compare complex expressions vs comments).
10
+ #
11
+ # Here's some examples of Complex Commodities:
12
+ # ```
13
+ # 10 AAPL @@ $500.00
14
+ # 10 AAPL @ ($500.00 / 10)
15
+ # (5 AAPL * 2) @ ($500.00 / 10)
16
+ # 1000 AAPL (@) $1
17
+ # -10 AAPL {{$500.00}} @@ $750.00
18
+ # 10 AAPL {=$50.00}
19
+ # -5 AAPL {$50.00} [2012-04-10] @@ $375.00
20
+ # -5 AAPL {$50.00} [2012-04-10] (Oh my!) @@ $375.00
21
+ # -5 AAPL {$50.00} ((ten_dollars)) @@ $375.00
22
+ # -5 AAPL {$50.00} ((s, d, t -> market($10, date, t))) @@ $375.00
23
+ # ```
24
+ #
25
+ # We ended up needing most of this class to run {RVGP::Validations::DuplicateTagsValidation}.
26
+ # And, to ensure that we're able to mostly-validate the syntax of the journals. We don't actually
27
+ # use many code paths here, otherwise. (Though we do use it to serialize currency conversion in
28
+ # the file shorthand/investment.rb)
29
+ #
30
+ # I'm not entirely sure what attribute names to use. We could go with intent
31
+ # position, or with class. Either path seems to introduce exceptions. Possibly
32
+ # some of these attributes should just go into the transfer class. I'm also not
33
+ # sure that the left_/right_/operation system makes sense.
34
+ #
35
+ # I also think we need some adjustments here to cover all parsing cases. But,
36
+ # for now this works well enough, again mostly because we're not using most
37
+ # of these code paths... Lets see if/how this evolves.
38
+ #
39
+ # @attr_reader [RVGP::Commodity::Journal] left The 'left' component of the complex commodity
40
+ # @attr_reader [RVGP::Commodity::Journal] right The 'right' component of the complex commodity
41
+ # @attr_reader [Symbol] operation The 'operation' component of the complex commodity, either :right_expression,
42
+ # :left_expression, :per_unit, or :per_lot
43
+ # @attr_reader [Date] left_date The 'left_date' component of the complex commodity
44
+ # @attr_reader [String] left_lot The 'left_lot' component of the complex commodity
45
+ # @attr_reader [Symbol] left_lot_operation The 'left_lot_operation' component of the complex commodity, either
46
+ # :per_unit, or :per_lot
47
+ # @attr_reader [TrueClass, FalseClass] left_lot_is_equal The 'left_lot_is_equal' component of the complex
48
+ # commodity
49
+ # @attr_reader [String] left_expression The 'left_expression' component of the complex commodity
50
+ # @attr_reader [String] right_expression The 'right_expression' component of the complex commodity
51
+ # @attr_reader [String] left_lambda The 'left_lambda' component of the complex commodity
52
+ # @attr_reader [TrueClass, FalseClass] left_is_equal The 'left_is_equal' component of the complex commodity
53
+ # @attr_reader [TrueClass, FalseClass] right_is_equal The 'right_is_equal' component of the complex commodity
54
+ class ComplexCommodity
55
+ # @!visibility private
56
+ LOT_MATCH = /\A(\{+) *(=?) *([^}]+)\}+(.*)\Z/.freeze
57
+ # @!visibility private
58
+ LAMBDA_MATCH = /\A\(\((.+)\)\)(.*)\Z/.freeze
59
+ # @!visibility private
60
+ OP_MATCH = /\A(@{1,2})(.*)\Z/.freeze
61
+ # @!visibility private
62
+ WHITESPACE_MATCH = /\A[ \t]+(.*)\Z/.freeze
63
+ # @!visibility private
64
+ EQUAL_MATCH = /\A=(.*)\Z/.freeze
65
+
66
+ # @!visibility private
67
+ DATE_MATCH = /\A\[(\d{4})-(\d{1,2})-(\d{1,2})\](.*)\Z/.freeze
68
+ # @!visibility private
69
+ COMMENT_MATCH = /\A\(([^)]+)\)(.*)\Z/.freeze
70
+ # @!visibility private
71
+ MSG_TOO_MANY = 'Too many %s in ComplexCommodity::from_s. Against: %s'
72
+ # @!visibility private
73
+ MSG_UNPARSEABLE = 'The ComplexCommodity::from_s "%s" appears to be unparseable'
74
+
75
+ # @!visibility private
76
+ ATTRIBUTES = %i[left right operation left_date left_lot left_lot_operation left_lot_is_equal left_expression
77
+ right_expression left_lambda left_is_equal right_is_equal].freeze
78
+
79
+ # This is written this way, to prevent yard from triggering an Undocumentable ATTRIBUTES warning.
80
+ send(:attr_reader, *ATTRIBUTES)
81
+
82
+ # Raised on a parse error
83
+ class Error < StandardError; end
84
+
85
+ # Create a complex commodity, from constituent parts
86
+ # @param [Hash] opts The parts of this complex commodity
87
+ # @option opts [String] code see {Commodity#code}
88
+ # @option opts [RVGP::Journal::Commodity] left see {ComplexCommodity#left}
89
+ # @option opts [RVGP::Journal::Commodity] right see {ComplexCommodity#right}
90
+ # @option opts [Symbol] operation see {ComplexCommodity#operation}
91
+ # @option opts [Date] left_date see {ComplexCommodity#left_date}
92
+ # @option opts [String] left_lot see {ComplexCommodity#left_lot}
93
+ # @option opts [Symbol] left_lot_operation see {ComplexCommodity#left_lot_operation}
94
+ # @option opts [TrueClass, FalseClass] left_lot_is_equal see {ComplexCommodity#left_lot_is_equal}
95
+ # @option opts [String] left_expression see {ComplexCommodity#left_expression}
96
+ # @option opts [String] right_expression see {ComplexCommodity#right_expression}
97
+ # @option opts [String] left_lambda see {ComplexCommodity#left_lambda}
98
+ # @option opts [TrueClass, FalseClass] left_is_equal see {ComplexCommodity#left_is_equal}
99
+ # @option opts [TrueClass, FalseClass] right_is_equal see {ComplexCommodity#right_is_equal}
100
+ def initialize(opts = {})
101
+ ATTRIBUTES.select { |attr| opts.key? attr }.each do |attr|
102
+ instance_variable_set("@#{attr}".to_sym, opts[attr])
103
+ end
104
+ end
105
+
106
+ # For now, we simply delegate these messages to the :left commodity. This path
107
+ # is only in use in the reconciler, which, determines whether the commodity
108
+ # is income or expense. (and/or inverts the amount) It's conceivable that we
109
+ # may want to test the right commodity at some point here, and determine if
110
+ # the net operation is positive or negative.
111
+ def positive?
112
+ left.positive?
113
+ end
114
+
115
+ # For now, we simply delegate this message to the :left commodity.
116
+ def invert!
117
+ left.invert!
118
+ self
119
+ end
120
+
121
+ # De-parse this ComplexCommodity, back into its string representation
122
+ # @return [String]
123
+ def to_s
124
+ [left_is_equal ? '=' : nil,
125
+ left ? left.to_s : nil,
126
+ if left_lot_operation && left_lot
127
+ format(left_lot_operation == :per_unit ? '{%s}' : '{{%s}}',
128
+ *[left_lot_is_equal ? '=' : nil, left_lot.to_s].compact.join)
129
+ end,
130
+ left_date ? format('[%s]', left_date.to_s) : nil,
131
+ left_expression ? format('(%s)', left_expression.to_s) : nil,
132
+ left_lambda ? format('((%s))', left_lambda.to_s) : nil,
133
+ if operation
134
+ operation == :per_unit ? '@' : '@@'
135
+ end,
136
+ right_is_equal ? '=' : nil,
137
+ right ? right.to_s : nil,
138
+ right_expression ? format('(%s)', right_expression.to_s) : nil].compact.join(' ')
139
+ end
140
+
141
+ # Given a string, in one of the supported formats, construct and return a commodity representation.
142
+ # @param [String] string The commodity, as would be found in a PTA journal
143
+ # @return [RVGP::Journal::ComplexCommodity]
144
+ def self.from_s(string)
145
+ tmp = string.dup
146
+ opts = {}
147
+
148
+ # We treat string like a kind of stack, and we pop off what we can parse
149
+ # from the left, heading to the right
150
+ until tmp.empty?
151
+ case tmp
152
+ when WHITESPACE_MATCH
153
+ tmp = ::Regexp.last_match(1)
154
+ when EQUAL_MATCH
155
+ side = opts[:operation] ? :right_is_equal : :left_is_equal
156
+ ensure_not_too_many! opts, side, string
157
+ opts[side] = true
158
+ tmp = ::Regexp.last_match(1)
159
+ when LOT_MATCH
160
+ ensure_not_too_many! opts, :left_lot, string
161
+ opts[:left_lot_operation] = to_operator ::Regexp.last_match(1)
162
+ opts[:left_lot_is_equal] = (::Regexp.last_match(2) == '=')
163
+ opts[:left_lot] = ::Regexp.last_match(3).to_commodity
164
+ tmp = ::Regexp.last_match(4)
165
+ when LAMBDA_MATCH
166
+ ensure_not_too_many! opts, :left_lambda, string
167
+ opts[:left_lambda] = ::Regexp.last_match(1)
168
+ tmp = ::Regexp.last_match(2)
169
+ when DATE_MATCH
170
+ ensure_not_too_many! opts, :left_date, string
171
+ opts[:left_date] = Date.new(*(1..3).map { |i| ::Regexp.last_match(i).to_i })
172
+ tmp = ::Regexp.last_match(4)
173
+ when OP_MATCH
174
+ ensure_not_too_many! opts, :operation, string
175
+ opts[:operation] = to_operator ::Regexp.last_match(1)
176
+ tmp = ::Regexp.last_match(2)
177
+ when COMMENT_MATCH
178
+ side = opts[:operation] ? :right_expression : :left_expression
179
+ ensure_not_too_many! opts, side, string
180
+ opts[side] = ::Regexp.last_match(1)
181
+ tmp = ::Regexp.last_match(2)
182
+ else
183
+ begin
184
+ commodity, tmp = RVGP::Journal::Commodity.from_s_with_remainder tmp
185
+ rescue RVGP::Journal::Commodity::Error
186
+ raise Error, MSG_UNPARSEABLE % string
187
+ end
188
+
189
+ side = opts[:operation] ? :right : :left
190
+ ensure_not_too_many! opts, side, string
191
+ opts[side] = commodity
192
+ end
193
+ end
194
+
195
+ new opts
196
+ end
197
+
198
+ # @!visibility private
199
+ def self.to_operator(from_s)
200
+ case from_s
201
+ when /\A[@{]\Z/ then :per_unit
202
+ when /\A(?:@@|{{)\Z/ then :per_lot
203
+ else
204
+ raise Error, format('Unrecognized operator %s', from_s.inspect)
205
+ end
206
+ end
207
+
208
+ # This mostly just saves us some typing above in the from_s()
209
+ def self.ensure_not_too_many!(opts, key, string)
210
+ raise Error, format(MSG_TOO_MANY, key.to_s, string) if opts.key? key
211
+ end
212
+ end
213
+ end
214
+ end