rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|