rvgp 0.3.2

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