rvgp 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
|
@@ -0,0 +1,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
|