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,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
|