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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RVGP
6
+ class Journal
7
+ # This abstraction offers a repository by which currencies can be defined,
8
+ # and by which their rules can be queried. An instance of this class, is an
9
+ # entry in the currency table, that we support. The default currencies that RVGP
10
+ # supports can be found in:
11
+ # {https://github.com/brighton36/rvgp/blob/main/resources/iso-4217-currencies.json iso-4217-currencies.json}
12
+ # , and this file is typically loaded during rvgp initialization.
13
+ #
14
+ # Here's what an entry in that file, looks like:
15
+ # ```
16
+ # {
17
+ # "Entity":"UNITED STATES",
18
+ # "Currency":"US Dollar",
19
+ # "Alphabetic Code":"USD",
20
+ # "Numeric Code":"840",
21
+ # "Minor unit":"2",
22
+ # "Symbol":"$"
23
+ # },
24
+ # ```
25
+ #
26
+ # PR's welcome, if you have additions to offer on this file.
27
+ #
28
+ # This class is used in several places throughout the codebase, and provides
29
+ # a standard set of interfaces for working with this global repository of
30
+ # currency properties.
31
+ # @attr_reader [String] entity The name of the institution to which this currency belongs. For some reason
32
+ # (I believe this is part of the ISO-4217 standard) this string is capitalized
33
+ # (ie, 'UNITED STATES')
34
+ # @attr_reader [String] currency A colloquial name for this currency (ie. "US Dollar")
35
+ # @attr_reader [String] alphabetic_code The shorthand, three digit letter code for this currency (ie 'USD')
36
+ # @attr_reader [Integer] numeric_code The ISO 4217 code for this currency 840
37
+ # @attr_reader [Integer] minor_unit The default precision for this currency, as would be typically implied.
38
+ # For the case of USD, this would be 2. Indicating two decimal digits for a
39
+ # default transcription of a USD amount.
40
+ # @attr_reader [String] symbol The shorthand, (typically) one character symbol code for this currency (ie '$')
41
+ class Currency
42
+ # Raised on a parse error
43
+ class Error < StandardError; end
44
+
45
+ attr_reader :entity, :currency, :alphabetic_code, :numeric_code, :minor_unit, :symbol
46
+
47
+ # Create a Currency commodity, from constituent parts
48
+ # @param [Hash] opts The parts of this complex commodity
49
+ # @option opts [String] entity see {Currency#entity}
50
+ # @option opts [String] currency see {Currency#currency}
51
+ # @option opts [String] alphabetic_code see {Currency#alphabetic_code}
52
+ # @option opts [Integer] numeric_code see {Currency#numeric_code}
53
+ # @option opts [Integer] minor_unit see {Currency#minor_unit}
54
+ # @option opts [String] symbol see {Currency#symbol}
55
+ def initialize(opts = {})
56
+ @entity = opts[:entity]
57
+ @currency = opts[:currency]
58
+ @alphabetic_code = opts[:alphabetic_code]
59
+ @numeric_code = opts[:numeric_code].to_i
60
+ @minor_unit = opts[:minor_unit].to_i
61
+ @symbol = opts[:symbol]
62
+ raise Error, format('Unabled to parse config entry: "%s"', inspect) unless valid?
63
+ end
64
+
65
+ # Indicates whether or not this instance contains all required fields
66
+ # @return [TrueClass,FalseClass] whether or not we're valid
67
+ def valid?
68
+ [entity, currency, alphabetic_code, numeric_code, minor_unit].all?
69
+ end
70
+
71
+ # Create a new commodity, from this currency, with the provided quantity
72
+ # @param [Integer] quantity The quantity component, of the newly created commodity
73
+ # @return [RVGP::Journal::Commodity]
74
+ def to_commodity(quantity)
75
+ RVGP::Journal::Commodity.new symbol || alphabetic_code, alphabetic_code, quantity, minor_unit
76
+ end
77
+
78
+ # Load and return a parsed RVGP::Journal::Currency, out of the provided
79
+ # {https://github.com/brighton36/rvgp/blob/main/resources/iso-4217-currencies.json iso-4217-currencies.json}
80
+ # file.
81
+ # @param [String] str Either a three digit :alphabetic_code, or a single digit :symbol
82
+ # @return [RVGP::Journal::Currency] the requested currency, with its default parameters
83
+ def self.from_code_or_symbol(str)
84
+ @currencies ||= begin
85
+ unless currencies_config && File.readable?(currencies_config)
86
+ raise StandardError, 'Missing currency config file'
87
+ end
88
+
89
+ JSON.parse(File.read(currencies_config)).map do |c|
90
+ new(c.transform_keys { |k| k.downcase.tr(' ', '_').to_sym })
91
+ end
92
+ end
93
+ @currencies.find { |c| (c.alphabetic_code && c.alphabetic_code == str) || (c.symbol && c.symbol == str) }
94
+ end
95
+
96
+ class << self
97
+ attr_accessor :currencies_config
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ # This class parses a pta journal, and offers that journal in its constitutent
5
+ # parts. See the {Journal.parse} for the typical entry point, into this class.
6
+ # This class itself, really only offers the one method, .parse, to parse a pta
7
+ # journal's contents. Most of the functionality in this class, is provided
8
+ # by the classes contained within it.
9
+ # @attr_reader [Array<RVGP::Journal::Posting>] postings The postings that were encountered in this journal
10
+ class Journal
11
+ # @!visibility private
12
+ MSG_MISSING_POSTING_SEPARATOR = 'Missing a blank line before line %d: %s'
13
+ # @!visibility private
14
+ MSG_UNRECOGNIZED_HEADER = 'Unrecognized posting header at line %d: %s'
15
+ # @!visibility private
16
+ MSG_INVALID_DATE = 'Invalid posting date at line %d: %s'
17
+ # @!visibility private
18
+ MSG_UNEXPECTED_TRANSFER = 'Unexpected transfer at line %d: %s'
19
+ # @!visibility private
20
+ MSG_UNEXPECTED_TAG = 'Unexpected tag at line %d: %s'
21
+ # @!visibility private
22
+ MSG_UNEXPECTED_LINE = 'Unexpected at line %d: %s'
23
+ # @!visibility private
24
+ MSG_INVALID_TRANSFER_COMMODITY = 'Unparseable or unimplemented commodity-parse in transfer at line %d: %s'
25
+ # @!visibility private
26
+ MSG_INVALID_POSTING = 'Invalid Posting at separator line %d: %s'
27
+ # @!visibility private
28
+ MSG_TOO_MANY_SEMICOLONS = 'Too many semicolons at line %d. Are these comments? %s'
29
+ # @!visibility private
30
+ MSG_UNPARSEABLE_TRANSFER = 'Something is wrong with this transfer at line %d: %s'
31
+
32
+ attr :postings
33
+
34
+ # Declare and initialize this Journal.
35
+ # @param [Array[RVGP::Journal::Posting]] postings An array of postings that this instance represents
36
+ def initialize(postings)
37
+ @postings = postings
38
+ end
39
+
40
+ # Unparse this journal, and return the parsed objects in their serialized form.
41
+ # @return [String] A pta journal. Presumably, the same one we were initialized from
42
+ def to_s
43
+ @postings.map(&:to_ledger).join "\n\n"
44
+ end
45
+
46
+ # Given a pta journal, already read from the filesystem, return a parsed representation of its contents.
47
+ # @param [String] contents A pta journal, as a string
48
+ # @return [RVGP::Journal] The parsed representation of the provided string
49
+ def self.parse(contents)
50
+ postings = []
51
+
52
+ posting = nil
53
+ cite = nil
54
+ contents.lines.each_with_index do |line, i|
55
+ line_number = i + 1
56
+ cite = [line_number, line.inspect] # in case we run into an error
57
+ line_comment = nil
58
+
59
+ # Here, we separate the line into non-comment lvalue and comment rvalue:
60
+ # NOTE: We're not supporting escaped semicolons, at this time
61
+ case line
62
+ when /\A.*[^\\];.*[^\\];.*\Z/
63
+ raise StandardError, format(MSG_TOO_MANY_SEMICOLONS, cite)
64
+ when /\A( *.*?) *;[ \t]*(.*)\Z/
65
+ line = ::Regexp.last_match(1)
66
+ line_comment = ::Regexp.last_match(2)
67
+ end
68
+
69
+ # This case parses anything to the left of a comment:
70
+ case line
71
+ when /\A([^ \n].*)\Z/
72
+ # This is a post declaration line
73
+ raise StandardError, MSG_MISSING_POSTING_SEPARATOR % cite if posting
74
+ unless %r{\A(\d{4})[/-](\d{2})[/-](\d{2}) +(.+?) *\Z}.match ::Regexp.last_match(1)
75
+ raise StandardError, MSG_UNRECOGNIZED_HEADER % cite
76
+ end
77
+
78
+ begin
79
+ date = Date.new ::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i
80
+ rescue Date::Error
81
+ raise StandardError, MSG_INVALID_DATE % cite
82
+ end
83
+
84
+ posting = Posting.new date, ::Regexp.last_match(4), line_number: line_number
85
+ when /\A[ \t]+([^ ].+)\Z/
86
+ # This is a transfer line, to be appended to the current posting
87
+ raise StandardError, MSG_UNEXPECTED_TRANSFER % cite unless posting
88
+
89
+ # NOTE: We chose 2 or more spaces as the separator between
90
+ # the account and the commodity, mostly because this was the smallest
91
+ # we could find in the official ledger documentation
92
+ unless /\A(.+?)(?: {2,}([^ ].+)| *)\Z/.match ::Regexp.last_match(1)
93
+ raise StandardError, format(MSG_UNPARSEABLE_TRANSFER, cite)
94
+ end
95
+
96
+ begin
97
+ posting.append_transfer ::Regexp.last_match(1), ::Regexp.last_match(2)
98
+ rescue RVGP::Journal::Commodity::Error
99
+ raise StandardError, MSG_INVALID_TRANSFER_COMMODITY % cite
100
+ end
101
+ when /\A[ \t]*\Z/
102
+ if line_comment.nil? && posting
103
+ unless posting.valid?
104
+ posting.transfers.each do |transfer|
105
+ puts format(' - Not valid. account %<acct>s commodity: %<commodity>s complex_commodity: %<complex>s',
106
+ acct: transfer.account.inspect,
107
+ commodity: transfer.commodity.inspect,
108
+ complex: transfer.complex_commodity.inspect)
109
+ end
110
+ end
111
+
112
+ raise StandardError, MSG_INVALID_POSTING % cite unless posting.valid?
113
+
114
+ # This is a blank line
115
+ postings << posting
116
+ posting = nil
117
+ end
118
+ else
119
+ raise StandardError, MSG_UNEXPECTED_LINE % cite unless posting
120
+ end
121
+
122
+ next unless line_comment && posting
123
+
124
+ tags = line_comment.scan(/(?:[^ ]+: *[^,]*|:[^ \t]+:)/).map do |declaration|
125
+ /\A:?(.+):\Z/.match(declaration) ? ::Regexp.last_match(1).split(':') : declaration
126
+ end.flatten
127
+
128
+ tags.each { |tag| posting.append_tag tag }
129
+ end
130
+
131
+ # The last line could be \n, which, makes this unnecessary
132
+ if posting
133
+ raise StandardError, MSG_INVALID_POSTING % cite unless posting.valid?
134
+
135
+ postings << posting
136
+ end
137
+
138
+ new postings
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ class Journal
5
+ # This class represents a single posting, in a PTA journal. A posting is
6
+ # typically of the following form:
7
+ # ```
8
+ # 2020-02-10 Frozen Chicken from the Local Supermarket
9
+ # Personal:Expenses:Food:Groceries $ 50.00
10
+ # Cash
11
+ # ```
12
+ # This is a simple example. There are a good number of permutations under which
13
+ # posting components s appear. Nonetheless, a posting is typically comprised of
14
+ # a date, a description, and a number of RVGP::Journal::Posting::Transfer lines,
15
+ # indented below these fields. This object represents the parsed format,
16
+ # of a post, traveling around the RVGP codebase.
17
+ # @attr_reader [Integer] line_number The line number, in a journal, that this posting was declared at.
18
+ # @attr_reader [Date] date The date this posting occurred
19
+ # @attr_reader [String] description The first line of this posting
20
+ # @attr_reader [Array<RVGP::Journal::Posting::Transfer>] transfers An array of transfers, that apply to this
21
+ # posting.
22
+ # @attr_reader [Array<RVGP::Journal::Posting::Tag>] tags An array of tags, that apply to this posting.
23
+ class Posting
24
+ # This class represents an indented 'transfer' line, within a posting.
25
+ # Typically, such lines takes the form of :
26
+ # ```
27
+ # Personal:Expenses:Food:Groceries $ 50.00
28
+ # ```
29
+ # This class offers few functions, and mostly just offers its attributes. Note
30
+ # that there should be no reason a posting ever has both is commodity and complex_commodity
31
+ # set. Either one or the other should exist, for any given Transfer.
32
+ # @attr_reader [String] account The account this posting is crediting or debiting
33
+ # @attr_reader [String] commodity The amount (expressed in commodity terms) being credit/debited
34
+ # @attr_reader [String] complex_commodity The amount (expressed in complex commodity terms) being credit/debited
35
+ # @attr_reader [Array<RVGP::Journal::Posting::Tag>] tags An array of tags, that apply to this posting.
36
+ class Transfer
37
+ attr :account, :commodity, :complex_commodity, :tags
38
+
39
+ # Create a complex commodity, from constituent parts
40
+ # @param [String] account see {Transfer#account}
41
+ # @param [Hash] opts Additional parts of this Transfer
42
+ # @option opts [String] commodity see {Transfer#commodity}
43
+ # @option opts [String] complex_commodity see {Transfer#complex_commodity}
44
+ # @option opts [Array<RVGP::Journal::Posting::Tag>] tags ([]) see {Transfer#tags}
45
+ def initialize(account, opts = {})
46
+ @account = account
47
+ @commodity = opts[:commodity]
48
+ @complex_commodity = opts[:complex_commodity]
49
+ @tags = opts[:tags] || []
50
+ end
51
+ end
52
+
53
+ # This class represents a key, or key/value tag, within a journal.
54
+ # These tags can be affixed to transfers and postings. And, are pretty
55
+ # simple, comprising of a key and optionally, a value.
56
+ # @attr_reader [String] key The label of this tag
57
+ # @attr_reader [String] value The value of this tag
58
+ class Tag
59
+ attr :key, :value
60
+
61
+ # Create a tag from it's constituent parts
62
+ # @param [String] key see {Tag#key}
63
+ # @param [String] value (nil) see {Tag#value}
64
+ def initialize(key, value = nil)
65
+ @key = key
66
+ @value = value
67
+ end
68
+
69
+ # Serialize this tag, to a string
70
+ # @return [String] the tag, as would be found in a pta journal
71
+ def to_s
72
+ value ? [key, value].join(': ') : key
73
+ end
74
+
75
+ # Parse the provided string, into a Tag object
76
+ # @param [String] str The tag, possibly a key/value pair, as would be found in a pta journal
77
+ # @return [Tag] A parsed representation of this tag
78
+ def self.from_s(str)
79
+ /\A(.+) *: *(.+)\Z/.match(str) ? Tag.new(::Regexp.last_match(1), ::Regexp.last_match(2)) : Tag.new(str)
80
+ end
81
+ end
82
+
83
+ attr :line_number, :date, :description, :transfers, :tags
84
+
85
+ # Create a posting, from constituent parts
86
+ # @param [Date] date see {Posting#date}
87
+ # @param [String] description see {Posting#description}
88
+ # @param [Hash] opts Additional parts of this Posting
89
+ # @option opts [Array<RVGP::Journal::Posting::Transfer>] transfers see {Posting#transfers}
90
+ # @option opts [Array<RVGP::Journal::Posting::Tag>] tags see {Posting#transfers}
91
+ def initialize(date, description, opts = {})
92
+ @line_number = opts[:line_number]
93
+ @date = date
94
+ @description = description
95
+ @transfers = opts.key?(:transfers) ? opts[:transfers] : []
96
+ @tags = opts.key?(:tags) ? opts[:tags] : []
97
+ end
98
+
99
+ # Indicates whether or not this instance contains all required fields
100
+ # @return [TrueClass,FalseClass] whether or not we're valid
101
+ def valid?
102
+ # Required fields:
103
+ [date, description, transfers, transfers.any? { |t| t.account && (t.commodity || t.complex_commodity) }].all?
104
+ end
105
+
106
+ # Serializes this posting into a string, in the form that would be found in a PTA journal
107
+ # @return [String] The PTA journal representation of this posting
108
+ def to_ledger
109
+ max_to_length = transfers.map do |transfer|
110
+ transfer.commodity || transfer.complex_commodity ? transfer.account.length : 0
111
+ end.max
112
+
113
+ lines = [[date, description].join(' ')]
114
+ lines.insert(lines.length > 1 ? -2 : 1, format(' ; %s', tags.join(', '))) if tags && !tags.empty?
115
+ lines += transfers.map do |transfer|
116
+ [
117
+ if transfer.commodity || transfer.complex_commodity
118
+ format(" %<account>-#{max_to_length}s %<commodity>s",
119
+ account: transfer.account,
120
+ commodity: (transfer.commodity || transfer.complex_commodity).to_s)
121
+ else
122
+ format(' %s', transfer.account)
123
+ end,
124
+ transfer.tags && !transfer.tags.empty? ? transfer.tags.map { |tag| format(' ; %s', tag) } : nil
125
+ ].compact.flatten.join("\n")
126
+ end
127
+ lines.join("\n")
128
+ end
129
+
130
+ # The append_*() is really only intended to be called by the parser:
131
+ # @!visibility private
132
+ def append_transfer(account_part, commodity_part)
133
+ opts = {}
134
+ if commodity_part
135
+ # Let's see if it'll parse as a commodity:
136
+ begin
137
+ opts[:commodity] = RVGP::Journal::Commodity.from_s commodity_part
138
+ rescue RVGP::Journal::Commodity::UnimplementedError
139
+ # Then let's see if it parses as a commodity pair
140
+ opts[:complex_commodity] = RVGP::Journal::ComplexCommodity.from_s commodity_part
141
+ end
142
+ end
143
+
144
+ @transfers << Transfer.new(account_part, opts)
145
+ end
146
+
147
+ # This is really only intended to simpify the parser, we push this onto the
148
+ # bottom of whatever exists here
149
+ # @!visibility private
150
+ def append_tag(as_string)
151
+ tag = Tag.from_s as_string
152
+ (transfers.empty? ? tags : transfers.last.tags) << tag
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module RVGP
6
+ class Journal
7
+ # This class takes a value, denominated in one commodity, and returns the equivalent value, in another commodity.
8
+ # This process is also known as price (or currency) exchange. The basis for exchanges are rates, affixed to a
9
+ # date. These exchange rates are expected to be provided in the same format that ledger and hledger use. Here's an
10
+ # example:
11
+ # ```
12
+ # P 2020-01-01 USD 0.893179 EUR
13
+ # P 2020-02-01 EUR 1.109275 USD
14
+ # P 2020-03-01 USD 0.907082 EUR
15
+ # ```
16
+ # It's typical for these exchange rates to exist in a project's journals/prices.db, but, the constructor to this
17
+ # class expects the contents of such a file, as a string. The conversion process is fairly smart, in that a
18
+ # specified rate, works 'both ways'. Meaning that, a price query will resolve based on any stipulation of
19
+ # equivalence between commodities. And, the matter of which code is to the left, or right, of a ratio, is
20
+ # undifferentiated from the inverse arrangement. This behavior, and most all others in this class, mimics the way
21
+ # ledger works, wrt price conversion.
22
+ # @attr_reader [Array<Pricer::Price>] prices_db A parsed representation of the prices file, based on what was passed
23
+ # in the constructor.
24
+ class Pricer
25
+ # This class represents a line, parsed from a prices journal. And, an instance of this class
26
+ # represents an exchange rate.
27
+ # This class contains a datetime, an amount, and two codes.
28
+ # @attr_reader [Time] at The time at which this exchange rate was declared in effect
29
+ # @attr_reader [String] lcode The character alphabetic code, or symbol for the left side of the exchange pair
30
+ # @attr_reader [String] rcode The character alphabetic code, or symbol for the right side of the exchange pair.
31
+ # This code should (always?) match the amount.code
32
+ # @attr_reader [RVGP::Journal::Commodity] amount The ratio of lcode, to rcode. Aka: The exchange rate.
33
+ class Price < RVGP::Base::Reader
34
+ readers :at, :lcode, :rcode, :amount
35
+
36
+ # A shortcut, to {RVGP::Journal::Pricer::Price.to_key}, if a caller is looking to use this price in a Hash
37
+ # @return [String] A code, intended for use in Hash table lookups
38
+ def to_key
39
+ self.class.to_key lcode, rcode
40
+ end
41
+
42
+ # Create a string, for this pair, that is unique to the codes, regardless of the order in which they're
43
+ # provided. This enables us to assert bidirectionality in the lookup of prices.
44
+ # @param [String] code1 A three character alphabetic currency code
45
+ # @param [String] code2 A three character alphabetic currency code
46
+ # @return [String] A code, intended for use in Hash table lookups
47
+ def self.to_key(code1, code2)
48
+ [code1, code2].sort.join(' ')
49
+ end
50
+ end
51
+
52
+ # This Error is raised when we're unable to perform a price conversion
53
+ class NoPriceError < StandardError; end
54
+
55
+ # @!visibility private
56
+ MSG_UNEXPECTED_LINE = 'Unexpected at line %d: %s'
57
+ # @!visibility private
58
+ MSG_INVALID_PRICE = 'Missing one or more required elements at line %d: %s'
59
+ # @!visibility private
60
+ MSG_INVALID_DATETIME = 'Invalid datetime at line %d: %s'
61
+
62
+ attr_reader :prices_db
63
+
64
+ # Create a Price exchanger, given a prices database
65
+ # @param [String] prices_content The contents of a prices.db file, defining the exchange rates.
66
+ # @param [Hash] opts Optional features
67
+ # @option opts [Proc<Time,String,String>] before_price_add
68
+ # This option calls the provided Proc with the parameters offered to {#add}.
69
+ # Mostly, this exists to solve a very specific bug that occurs under certain
70
+ # conditions, in projects where currencies are automatically converted by
71
+ # ledger. If you see the I18n.t(error.missing_entry_in_prices_db) message
72
+ # in your build log, scrolling by - you should almost certainly add that entry
73
+ # to your project's prices.db. And this option is how that notice was fired.
74
+ #
75
+ # This option 'addresses' a pernicious bug that will likely affect you. And
76
+ # I don't have an easy solution, as, I sort of blame ledger for this.
77
+ # The problem will manifest itself in the form of grids that output
78
+ # differently, depending on what grids were built in the process.
79
+ #
80
+ # So, If, say, we're only building 2022 grids. But, a clean build
81
+ # would have built 2021 grids, before instigating the 2022 grid
82
+ # build - then, we would see different outputs in the 2022-only build.
83
+ #
84
+ # The reason for this, is that there doesn't appear to be any way of
85
+ # accounting for all historical currency conversions in ledger's output.
86
+ # The data coming out of ledger only includes currency conversions in
87
+ # the output date range. This will sometimes cause weird discrepencies
88
+ # in the totals between a 2021-2022 run, vs a 2022-only run.
89
+ #
90
+ # The only solution I could think of, at this time, was to burp on
91
+ # any occurence, where, a conversion, wasn't already in the prices.db
92
+ # That way, an operator (you) can simply add the outputted burp, into
93
+ # the prices.db file. This will ensure consistency in all grids,
94
+ # regardless of the ranges you run them.
95
+ #
96
+ # NOTE: This feature is currently unimplemnted in hledger. And, I have no
97
+ # solution planned there at this time. Probably, that means you should
98
+ # only use ledger in your project, if you're working with multiple currencies,
99
+ # and don't want to rebuild your project from clean, every time you make
100
+ # non-trivial changes.
101
+ #
102
+ # If you have a better idea, or some other way to ensure consistency
103
+ # (A SystemValidation?)... PR's welcome!
104
+ def initialize(prices_content = nil, opts = {})
105
+ @prices_db = prices_content ? parse(prices_content) : {}
106
+ @before_price_add = opts[:before_price_add] if opts[:before_price_add]
107
+ end
108
+
109
+ # Retrieve an exchange rate, for a given commodity, to another commodity, at a given time.
110
+ # @param [Time] at The time at which you want to query for an exchange rate. The most-recently-availble and
111
+ # eligible entry, before this parameter, will be selected.
112
+ # @param [String] from The three character alphabetic currency code, of the source currency
113
+ # @param [String] to The three character alphabetic currency code, of the destination currency
114
+ # @return [RVGP::Journal::Commodity] An exchange rate, denominated in units of the :to currency
115
+ def price(at, from, to)
116
+ no_price! at, from, to if prices_db.nil? || prices_db.empty?
117
+
118
+ lcurrency = RVGP::Journal::Currency.from_code_or_symbol from
119
+ from_alpha = lcurrency ? lcurrency.alphabetic_code : from
120
+
121
+ rcurrency = RVGP::Journal::Currency.from_code_or_symbol to
122
+ to_alpha = rcurrency ? rcurrency.alphabetic_code : to
123
+
124
+ prices = prices_db[Price.to_key(from_alpha, to_alpha)]
125
+
126
+ no_price! at, from, to unless prices && !prices.empty? && at >= prices.first.at
127
+
128
+ price = nil
129
+
130
+ 1.upto(prices.length - 1) do |i|
131
+ if prices[i].at > at
132
+ price = prices[i - 1]
133
+ break
134
+ end
135
+ end
136
+
137
+ price = prices.last if price.nil? && prices.last.at <= at
138
+
139
+ no_price! at, from, to unless price
140
+
141
+ # OK, so we have the price record that applies. But, it may need to be
142
+ # inverted.
143
+ if price.lcode == from_alpha && price.amount.alphabetic_code == to_alpha
144
+ price.amount
145
+ else
146
+ RVGP::Journal::Commodity.from_symbol_and_amount to,
147
+ (1 / price.amount.quantity_as_bigdecimal).round(17).to_s('F')
148
+ end
149
+ end
150
+
151
+ # Convert the provided commodity, to another commodity, based on the rate at a given time.
152
+ # @param [Time] at The time at which you want to query for an exchange rate. The most-recently-availble and
153
+ # eligible entry, before this parameter, will be selected.
154
+ # @param [RVGP::Journal::Commodity] from_commodity The commodity you wish to convert
155
+ # @param [String] to_code_or_symbol The three character alphabetic currency code, or symbol, of the destination
156
+ # currency you wish to convert to.
157
+ # @return [RVGP::Journal::Commodity] The resulting commodity, in units of :to_code_or_symbol
158
+ def convert(at, from_commodity, to_code_or_symbol)
159
+ rate = price at, from_commodity.code, to_code_or_symbol
160
+
161
+ RVGP::Journal::Commodity.from_symbol_and_amount(
162
+ to_code_or_symbol,
163
+ (from_commodity.quantity_as_bigdecimal * rate.quantity_as_bigdecimal).to_s('F')
164
+ )
165
+ end
166
+
167
+ # Add a conversion rate to the database
168
+ # @param [Time] time The time at which this rate was discovered
169
+ # @param [String] from_alpha The three character alphabetic currency code, of the source currency
170
+ # @param [RVGP::Journal::Currency] to A commodity, expressing the quantity and commodity, that one
171
+ # unit of :from_alpha converts to
172
+ # @return [void]
173
+ def add(time, from_alpha, to)
174
+ lcurrency = RVGP::Journal::Currency.from_code_or_symbol from_alpha
175
+
176
+ price = Price.new time.to_time,
177
+ lcurrency ? lcurrency.alphabetic_code : from_alpha,
178
+ to.alphabetic_code || to.code,
179
+ to
180
+
181
+ key = price.to_key
182
+ if @prices_db.key? key
183
+ i = @prices_db[key].find_index { |p| p.at > price.at.to_time }
184
+
185
+ # There's no need to add the price, if there's no difference between
186
+ # what we're adding, and what would have been found, otherwise
187
+ price_before_add = i ? @prices_db[key][i - 1] : @prices_db[key].last
188
+
189
+ if price_before_add.amount != price.amount
190
+ @before_price_add&.call time, from_alpha, to
191
+
192
+ if i
193
+ @prices_db[key].insert i, price
194
+ else
195
+ @prices_db[key] << price
196
+ end
197
+ end
198
+
199
+ else
200
+ @before_price_add&.call time, from_alpha, to
201
+ @prices_db[key] = [price]
202
+ end
203
+
204
+ price
205
+ end
206
+
207
+ private
208
+
209
+ def parse(contents)
210
+ posting = nil
211
+ parsed_lines = contents.lines.map.with_index do |line, i|
212
+ cite = [i + 1, line.inspect] # in case we run into an error
213
+
214
+ # Remove any comments from the line:
215
+ line = ::Regexp.last_match(1) if /(.*) *;.*/.match line
216
+
217
+ case line
218
+ when %r{\AP[ ]+
219
+ # Date:
220
+ (\d{4}[\-/]\d{1,2}[\-/]\d{1,2})
221
+ # Time:
222
+ (?:[ ]+(\d{1,2}:\d{1,2}:\d{1,2})|)
223
+ # Symbol:
224
+ [ ]+([^ ]+)
225
+ # Commodity:
226
+ [ ]+(.+?)
227
+ [ ]*\Z}x
228
+
229
+ # NOTE: This defaults to the local time zone. Not sure if we care.
230
+ begin
231
+ time = Time.new(
232
+ *[::Regexp.last_match(1).tr('/', '-').split('-').map(&:to_i),
233
+ ::Regexp.last_match(2) ? ::Regexp.last_match(2).split(':').map(&:to_i) : nil].flatten.compact
234
+ )
235
+ rescue ArgumentError
236
+ raise StandardError, MSG_INVALID_DATETIME % cite
237
+ end
238
+
239
+ lcurrency = RVGP::Journal::Currency.from_code_or_symbol ::Regexp.last_match(3)
240
+ amount = ::Regexp.last_match(4).to_commodity
241
+
242
+ Price.new time,
243
+ lcurrency ? lcurrency.alphabetic_code : ::Regexp.last_match(3),
244
+ amount.alphabetic_code || amount.code, amount
245
+
246
+ when /\A *\Z/
247
+ # Blank Line
248
+ nil
249
+ else
250
+ raise StandardError, MSG_UNEXPECTED_LINE % cite unless posting
251
+ end
252
+ end.compact.sort_by(&:at)
253
+
254
+ parsed_lines.each_with_object({}) do |price, sum|
255
+ key = price.to_key
256
+ sum[key] = [] unless sum.key? key
257
+ sum[key] << price
258
+ sum
259
+ end
260
+ end
261
+
262
+ def no_price!(at, from, to)
263
+ raise NoPriceError, format('Unable to convert %<from>s to %<to>s at %<at>s', from: from, to: to, at: at.to_s)
264
+ end
265
+ end
266
+ end
267
+ end