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,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RVGP
|
4
|
+
module Reconcilers
|
5
|
+
# This module contains the built-in Shorthand classes that ship with RVGP. For more details on this
|
6
|
+
# feature, see the 'Shorthand' section of the {RVGP::Reconcilers} module.
|
7
|
+
module Shorthand
|
8
|
+
# This reconciler module will automatically allocate the proceeds (or losses) from a stock sale.
|
9
|
+
# This module will allocate capital gains or losses, given a symbol, amount, and price.
|
10
|
+
#
|
11
|
+
# The module parameters we support are:
|
12
|
+
# - **symbol** [String] - A commodity or currency code, that represents the purchased asset
|
13
|
+
# - **amount** [Integer] - The amount of :symbol that was purchased, or if negative, sold.
|
14
|
+
# - **price** [Commodity] - A unit price, for the symbol. This field should be delimited if :total is omitted.
|
15
|
+
# - **total** [Commodity] - A lot price, the symbol. This represents the net purchase price, which, would be
|
16
|
+
# divided by the amount, in order to arrive at a unit price. This field should be delimited if :price is
|
17
|
+
# omitted.
|
18
|
+
# - **capital_gains** [Commodity] - The amount of the total, to allocate to a capital gains account. Presumably
|
19
|
+
# for tax reporting.
|
20
|
+
# - **gains_account** [String] - The account name to allocate capital gains to.
|
21
|
+
#
|
22
|
+
# # Example
|
23
|
+
# Here's how this module might be used in your reconciler:
|
24
|
+
# ```
|
25
|
+
# ...
|
26
|
+
# - match: /Acme Stonk Exchange/
|
27
|
+
# to_shorthand: Investment
|
28
|
+
# shorthand_params:
|
29
|
+
# symbol: VOO
|
30
|
+
# price: "$ 400.00"
|
31
|
+
# amount: "-1000"
|
32
|
+
# capital_gains: "$ -100000.00"
|
33
|
+
# gains_account: Personal:Income:AcmeExchange:VOO
|
34
|
+
# ...
|
35
|
+
# ```
|
36
|
+
# And here's how that will reconcile, in your build:
|
37
|
+
# ```
|
38
|
+
# ...
|
39
|
+
# 2023-06-01 Acme Stonk Exchange ACH CREDIT 123456 Yukihiro Matsumoto
|
40
|
+
# Personal:Assets -1000 VOO @@ $ 400000.00
|
41
|
+
# Personal:Income:AcmeExchange:VOO $ 100000.00
|
42
|
+
# Personal:Assets:AcmeChecking
|
43
|
+
# ...
|
44
|
+
# ```
|
45
|
+
#
|
46
|
+
class Investment
|
47
|
+
# @!visibility private
|
48
|
+
attr_reader :tag, :symbol, :price, :amount, :total, :capital_gains,
|
49
|
+
:remainder_amount, :remainder_account, :targets, :is_sell,
|
50
|
+
:to, :gains_account
|
51
|
+
|
52
|
+
def initialize(rule)
|
53
|
+
@tag = rule[:tag]
|
54
|
+
@targets = rule[:targets]
|
55
|
+
@to = rule[:to] || 'Personal:Assets'
|
56
|
+
|
57
|
+
if rule.key? :shorthand_params
|
58
|
+
@symbol = rule[:shorthand_params][:symbol]
|
59
|
+
@amount = rule[:shorthand_params][:amount]
|
60
|
+
@gains_account = rule[:shorthand_params][:gains_account]
|
61
|
+
|
62
|
+
%w[price total capital_gains].each do |key|
|
63
|
+
if rule[:shorthand_params].key? key.to_sym
|
64
|
+
instance_variable_set "@#{key}".to_sym, rule[:shorthand_params][key.to_sym].to_commodity
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
unless [symbol, amount].all?
|
70
|
+
raise StandardError, format('Investment at line:%s missing fields', rule[:line].inspect)
|
71
|
+
end
|
72
|
+
|
73
|
+
@is_sell = (amount.to_f <= 0)
|
74
|
+
|
75
|
+
# I mostly just think this doesn't make any sense... I guess if we took a
|
76
|
+
# loss...
|
77
|
+
raise StandardError, format('Unimplemented %s', rule.inspect) if capital_gains && !is_sell
|
78
|
+
|
79
|
+
if (gains_account.nil? || capital_gains.nil?) && is_sell
|
80
|
+
raise StandardError, format('Investment at line:%s missing gains_account', rule.inspect)
|
81
|
+
end
|
82
|
+
|
83
|
+
unless total || price
|
84
|
+
raise StandardError, format('Investment at line:%s missing an price or total', rule[:line].inspect)
|
85
|
+
end
|
86
|
+
|
87
|
+
if total && price
|
88
|
+
raise StandardError, format('Investment at line:%s specified both price and total', rule[:line].inspect)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# @!visibility private
|
93
|
+
def to_tx(from_posting)
|
94
|
+
income_targets = []
|
95
|
+
|
96
|
+
# NOTE: I pulled most of this from: https://hledger.org/investments.html
|
97
|
+
if is_sell && capital_gains
|
98
|
+
# NOTE: I'm not positive about this .abs....
|
99
|
+
cost_basis = (total || (price * amount.to_f.abs)) - capital_gains
|
100
|
+
|
101
|
+
income_targets << { to: to,
|
102
|
+
complex_commodity: RVGP::Journal::ComplexCommodity.new(
|
103
|
+
left: [amount, symbol].join(' ').to_commodity,
|
104
|
+
operation: :per_lot,
|
105
|
+
right: cost_basis
|
106
|
+
) }
|
107
|
+
|
108
|
+
income_targets << { to: gains_account, commodity: capital_gains.dup.invert! } if capital_gains
|
109
|
+
else
|
110
|
+
income_targets << { to: to,
|
111
|
+
complex_commodity: RVGP::Journal::ComplexCommodity.new(
|
112
|
+
left: [amount, symbol].join(' ').to_commodity,
|
113
|
+
operation: (price ? :per_unit : :per_lot),
|
114
|
+
right: price || total
|
115
|
+
) }
|
116
|
+
end
|
117
|
+
|
118
|
+
if targets
|
119
|
+
income_targets += targets.map do |t|
|
120
|
+
ret = { to: t[:to] }
|
121
|
+
if t.key? :amount
|
122
|
+
# TODO: I think there's a bug here, in that amounts with commodities, won't parse...
|
123
|
+
ret[:commodity] = RVGP::Journal::Commodity.from_symbol_and_amount t[:currency] || '$', t[:amount].to_s
|
124
|
+
end
|
125
|
+
|
126
|
+
if t.key? :complex_commodity
|
127
|
+
ret[:complex_commodity] = RVGP::Journal::ComplexCommodity.from_s t[:complex_commodity]
|
128
|
+
end
|
129
|
+
|
130
|
+
ret
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
RVGP::Base::Reconciler::Posting.new from_posting.line_number,
|
135
|
+
date: from_posting.date,
|
136
|
+
description: from_posting.description,
|
137
|
+
from: from_posting.from,
|
138
|
+
tags: from_posting.tags,
|
139
|
+
targets: income_targets
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
gem 'finance'
|
4
|
+
require 'finance'
|
5
|
+
require_relative './finance_gem_hacks'
|
6
|
+
|
7
|
+
module RVGP
|
8
|
+
module Reconcilers
|
9
|
+
module Shorthand
|
10
|
+
# This reconciler module will automatically allocate the the escrow, principal, and interest components of a
|
11
|
+
# mortage debit, into constituent accounts. The amounts of each, are automatically calculated, based on the loan
|
12
|
+
# terms, and taking the residual leftover, into a escrow account, presumably for taxes and insurance to be paid by
|
13
|
+
# the mortgage provider.
|
14
|
+
#
|
15
|
+
# Its important to note, that a single reconciler rule will match every mortgage payment encountered. And, that
|
16
|
+
# each of these payments will generate four transactions in the output file:
|
17
|
+
# - The initial payment, which, will be transferred to an :intermediary_account
|
18
|
+
# - A principal payment, which, will be debited from the :intermediary account, to the :payee_principal
|
19
|
+
# - An interest payment, which, will be debited from the :intermediary account, to the :payee_interest
|
20
|
+
# - An escrow payment, which, will be debited from the :intermediary account, to the :escrow_account
|
21
|
+
# You can see details of this expansion under the example section below.
|
22
|
+
#
|
23
|
+
# With regards to the :escrow_account, It's likely that you'll want to either (choose one):
|
24
|
+
# - Manually transcribe debits from the escrow account, to escrow payees, in your project's
|
25
|
+
# ./journal/property-name-escrow.journal, based on when your mortgage provider alerts you to these payments.
|
26
|
+
# - Download a csv from your mortgage provider, of your escrow account (if they offer one), and define a
|
27
|
+
# reconciler to allocate escrow payments.
|
28
|
+
#
|
29
|
+
# The module parameters we support are:
|
30
|
+
# - **label** [String] - This is a prefix, used in the description of Principal, Interest, and Escrow transactions
|
31
|
+
# - **principal** [Commodity] - The mortgage principal
|
32
|
+
# - **rate** [Float] - The mortgage rate
|
33
|
+
# - **payee_principal** [String] - The account to ascribe principal payments to
|
34
|
+
# - **payee_interest** [String] - The account to ascribe interest payments to
|
35
|
+
# - **escrow_account** [String] - Te account to ascribe escrow payments to
|
36
|
+
# - **intermediary_account** [String] - The account to ascribe intermediary payments to, from the source account,
|
37
|
+
# before being assigned to principal, interest, and escrow break-outs.
|
38
|
+
# - **start_at_installment_number** [Integer] - The installment number, of the first matching transaction,
|
39
|
+
# encountered by this module. Year one of a mortgage would start at zero. Subsequent annual reconcilers would
|
40
|
+
# be expected to define an installment number from which calculations can automatically pick-up the work
|
41
|
+
# from years prior.
|
42
|
+
# - **additional_payments** [Array<Hash>] - Any additional payments, to apply to the principal, can be listed
|
43
|
+
# here. This field is expected to be an array of hashes, which, are composed of the following fields:
|
44
|
+
# - **before_installment** [Integer] - The payment number, before which, this :amount should apply
|
45
|
+
# - **amount** [Float] - A float that will be deducted from the principal. No commodity is necessary to
|
46
|
+
# delineate, as we assume the same commodity as the :principle.
|
47
|
+
# - **override_payments** [Array<Hash>] - I can't explain why this is necessary. But, it seems that the interest
|
48
|
+
# calculations used by some mortgage providers ... aren't accurate. This happened to me, at least. The
|
49
|
+
# calculation being used was off by a penny, on a single installment. And, I didn't care enough to call the
|
50
|
+
# institution and figure out why. So, I added this feature, to allow an override of the automatic calculation,
|
51
|
+
# with the amount provided. This field is expected to be an array of hashes, which are composed of the following
|
52
|
+
# fields:
|
53
|
+
# - **at_installment** [Integer] - The payment number to assert the :interest value.
|
54
|
+
# - **interest** [Float] - The amount of the interest calculation. No commodity is neccessary to delineate, as
|
55
|
+
# we assume the same commodity as the :principle.
|
56
|
+
#
|
57
|
+
# # Example
|
58
|
+
# Here's how this module might be used in your reconciler:
|
59
|
+
# ```
|
60
|
+
# ...
|
61
|
+
# - match: /AcmeFinance Servicing/
|
62
|
+
# to_shorthand: Mortgage
|
63
|
+
# shorthand_params:
|
64
|
+
# label: 1-8-1 Yurakucho Dori Mortgage
|
65
|
+
# intermediary_account: Personal:Expenses:Banking:MortgagePayment:181Yurakucho
|
66
|
+
# payee_principal: Personal:Liabilities:Mortgage:181Yurakucho
|
67
|
+
# payee_interest: Personal:Expenses:Banking:Interest:181Yurakucho
|
68
|
+
# escrow_account: Personal:Assets:AcmeFinance:181YurakuchoEscrow
|
69
|
+
# principal: 260000.00
|
70
|
+
# rate: 0.0499
|
71
|
+
# start_at_installment_number: 62
|
72
|
+
# ...
|
73
|
+
# ```
|
74
|
+
# And here's how that will reconcile one of your payments, in your build:
|
75
|
+
# ```
|
76
|
+
# ...
|
77
|
+
# 2023-01-03 AcmeFinance Servicing MTG PYMT 012345 Yukihiro Matsumoto
|
78
|
+
# Personal:Expenses:Banking:MortgagePayment:181Yurakucho $ 3093.67
|
79
|
+
# Personal:Assets:AcmeBank:Checking
|
80
|
+
#
|
81
|
+
# 2023-01-03 1-8-1 Yurakucho Dori Mortgage (#61) Principal
|
82
|
+
# Personal:Liabilities:Mortgage:181Yurakucho $ 403.14
|
83
|
+
# Personal:Expenses:Banking:MortgagePayment:181Yurakucho
|
84
|
+
#
|
85
|
+
# 2023-01-03 1-8-1 Yurakucho Dori Mortgage (#61) Interest
|
86
|
+
# Personal:Expenses:Banking:Interest:181Yurakucho $ 991.01
|
87
|
+
# Personal:Expenses:Banking:MortgagePayment:181Yurakucho
|
88
|
+
#
|
89
|
+
# 2023-01-03 1-8-1 Yurakucho Dori Mortgage (#61) Escrow
|
90
|
+
# Personal:Assets:AcmeFinance:181YurakuchoEscrow $ 1699.52
|
91
|
+
# Personal:Expenses:Banking:MortgagePayment:181Yurakucho
|
92
|
+
# ...
|
93
|
+
# ```
|
94
|
+
# Note that you'll have an automatically calculated reconcilation for each payment you
|
95
|
+
# make, during the year. A single reconciler rule, will take care of reconciling every
|
96
|
+
# payment, automatically.
|
97
|
+
class Mortgage
|
98
|
+
# @!visibility private
|
99
|
+
attr_accessor :principal, :rate, :start_at_installment_number,
|
100
|
+
:additional_payments, :amortization, :payee_principal, :payee_interest,
|
101
|
+
:intermediary_account, :currency, :label, :escrow_account, :override_payments
|
102
|
+
|
103
|
+
# @!visibility private
|
104
|
+
def initialize(rule)
|
105
|
+
@label = rule[:shorthand_params][:label]
|
106
|
+
@currency = rule[:currency] || '$'
|
107
|
+
@principal = rule[:shorthand_params][:principal].to_commodity
|
108
|
+
@rate = rule[:shorthand_params][:rate]
|
109
|
+
@payee_principal = rule[:shorthand_params][:payee_principal]
|
110
|
+
@payee_interest = rule[:shorthand_params][:payee_interest]
|
111
|
+
@intermediary_account = rule[:shorthand_params][:intermediary_account]
|
112
|
+
@escrow_account = rule[:shorthand_params][:escrow_account]
|
113
|
+
@start_at_installment_number = rule[:shorthand_params][:start_at_installment_number]
|
114
|
+
@additional_payments = rule[:shorthand_params][:additional_payments]
|
115
|
+
@override_payments = {}
|
116
|
+
if rule[:shorthand_params].key? :override_payments
|
117
|
+
rule[:shorthand_params][:override_payments].each do |override|
|
118
|
+
unless %i[at_installment interest].all? { |k| override.key? k }
|
119
|
+
raise StandardError, format('Invalid Payment Override : %s', override)
|
120
|
+
end
|
121
|
+
|
122
|
+
@override_payments[ override[:at_installment] ] = {
|
123
|
+
interest: RVGP::Journal::Commodity.from_symbol_and_amount(currency, override[:interest])
|
124
|
+
}
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
unless [principal, rate, payee_principal, payee_interest, intermediary_account, escrow_account, label].all?
|
129
|
+
raise StandardError, format('Mortgage at line:%d missing fields', rule[:line])
|
130
|
+
end
|
131
|
+
|
132
|
+
fr = Finance::Rate.new rate, :apr, duration: 360
|
133
|
+
@amortization = principal.to_f.amortize(fr) do |period, amortization|
|
134
|
+
additional_payments&.each do |ap|
|
135
|
+
if period == ap[:before_installment]
|
136
|
+
amortization.balance = amortization.balance - DecNum.new(ap[:amount].to_s)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
@installment_i = start_at_installment_number ? (start_at_installment_number - 1) : 0
|
142
|
+
end
|
143
|
+
|
144
|
+
# @!visibility private
|
145
|
+
def to_tx(from_posting)
|
146
|
+
payment = RVGP::Journal::Commodity.from_symbol_and_amount(currency, amortization.payments[@installment_i]).abs
|
147
|
+
interest = RVGP::Journal::Commodity.from_symbol_and_amount(currency, amortization.interest[@installment_i])
|
148
|
+
|
149
|
+
interest = @override_payments[@installment_i][:interest] if @override_payments.key? @installment_i
|
150
|
+
|
151
|
+
principal = payment - interest
|
152
|
+
escrow = from_posting.commodity.abs - payment
|
153
|
+
total = principal + interest + escrow
|
154
|
+
|
155
|
+
@installment_i += 1
|
156
|
+
|
157
|
+
intermediary_opts = { date: from_posting.date, from: intermediary_account, tags: from_posting.tags }
|
158
|
+
|
159
|
+
[RVGP::Base::Reconciler::Posting.new(from_posting.line_number,
|
160
|
+
date: from_posting.date,
|
161
|
+
description: from_posting.description,
|
162
|
+
from: from_posting.from,
|
163
|
+
tags: from_posting.tags,
|
164
|
+
targets: [to: intermediary_account, commodity: total]),
|
165
|
+
# Principal:
|
166
|
+
RVGP::Base::Reconciler::Posting.new(
|
167
|
+
from_posting.line_number,
|
168
|
+
intermediary_opts.merge({ description: format('%<label>s (#%<num>d) Principal',
|
169
|
+
label: label,
|
170
|
+
num: @installment_i - 1),
|
171
|
+
targets: [{ to: payee_principal, commodity: principal }] })
|
172
|
+
),
|
173
|
+
|
174
|
+
# Interest:
|
175
|
+
RVGP::Base::Reconciler::Posting.new(
|
176
|
+
from_posting.line_number,
|
177
|
+
intermediary_opts.merge({ description: format('%<label>s (#%<num>d) Interest',
|
178
|
+
label: label,
|
179
|
+
num: @installment_i - 1),
|
180
|
+
targets: [{ to: payee_interest, commodity: interest }] })
|
181
|
+
),
|
182
|
+
|
183
|
+
# Escrow:
|
184
|
+
RVGP::Base::Reconciler::Posting.new(
|
185
|
+
from_posting.line_number,
|
186
|
+
intermediary_opts.merge({ description: format('%<label>s (#%<num>d) Escrow',
|
187
|
+
label: label,
|
188
|
+
num: @installment_i - 1),
|
189
|
+
targets: [{ to: escrow_account, commodity: escrow }] })
|
190
|
+
)]
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
module RVGP
|
6
|
+
module Utilities
|
7
|
+
# This class provides a number of utility functions to query, and merge, grids
|
8
|
+
# that have been built. The primary purpose of these tools, is to decrease
|
9
|
+
# the overhead that would otherwise exist, if we were to operate directly on
|
10
|
+
# the pta output, every time we referenced this data. As well as to maintain
|
11
|
+
# auditability for this data, in the project build.
|
12
|
+
# @attr_reader [Array<String>] headers The first row of a grid, without the keystone included
|
13
|
+
# @attr_reader [Hash<String,Hash<String,Object>>] data A hash whose key is the series name, whose value is a hash of
|
14
|
+
# headername to value pairs
|
15
|
+
# @attr_reader [String] keystone The cell in the upper-left of the grid. Often this cell is used as a superordinate
|
16
|
+
# label for the series below it.
|
17
|
+
class GridQuery
|
18
|
+
# NOTE: I'm not exactly sure what this class wants to be just yet...
|
19
|
+
# Let's see if we end up using it for graphing... It might just be a 'Spreadsheet'
|
20
|
+
# and we may want/need to move the summary columns into here
|
21
|
+
attr_reader :headers, :data, :keystone
|
22
|
+
|
23
|
+
# Setup a GridQuery
|
24
|
+
# @param [Array<String>] from_files An array of paths, to grid files
|
25
|
+
# @param [Hash] options Additional options
|
26
|
+
# @option options [String] keystone see {RVGP::Utilities::GridQuery#keystone}
|
27
|
+
# @option options [Proc] store_cell This proc is provided one parameter, a data cell. The return value of this
|
28
|
+
# proc is then stored in :data, in lieu of the parameter.
|
29
|
+
# @option options [Proc] select_columns This proc is provided two parameters, a header (String), and the cells
|
30
|
+
# underneath it in the grid (Array<Object>). If this proc returns true,
|
31
|
+
# that column is stored in :data. If this proc returns false, that column
|
32
|
+
# is not retained in this query.
|
33
|
+
# @option options [Proc] select_rows This proc is provided two parameters, a series_name (String), and the cells
|
34
|
+
# to the right of it in the grid (Array<Object>). If this proc returns true,
|
35
|
+
# that column is stored in :data. If this proc returns false, that column
|
36
|
+
# is not retained in this query.
|
37
|
+
def initialize(from_files, options = {})
|
38
|
+
@headers = []
|
39
|
+
@data = {}
|
40
|
+
|
41
|
+
from_files.each do |file|
|
42
|
+
csv = CSV.open file, 'r', headers: true
|
43
|
+
rows = csv.read
|
44
|
+
headers = csv.headers
|
45
|
+
|
46
|
+
# We assume the first column of the row, is the series name
|
47
|
+
@keystone = headers.shift
|
48
|
+
|
49
|
+
if options[:select_columns]
|
50
|
+
selected_headers = headers.select do |header|
|
51
|
+
options[:select_columns].call(header, rows.map { |row| row[header] })
|
52
|
+
end
|
53
|
+
end
|
54
|
+
selected_headers ||= headers
|
55
|
+
|
56
|
+
add_columns selected_headers
|
57
|
+
|
58
|
+
rows.each do |row|
|
59
|
+
series_data = row.to_h
|
60
|
+
series_name = series_data.delete @keystone
|
61
|
+
if options.key? :store_cell
|
62
|
+
series_data.each do |col, cell|
|
63
|
+
next unless !options.key?(:select_columns) || selected_headers.include?(col)
|
64
|
+
|
65
|
+
series_data[col] = options[:store_cell].call cell
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
if !options.key?(:select_rows) || options[:select_rows].call(series_name, series_data)
|
70
|
+
add_data series_name, series_data
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# This needs to be assigned after we've processed the csv's
|
76
|
+
@keystone = options[:keystone] if options.key? :keystone
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns the results of a query.
|
80
|
+
# @param [Hash] opts Additional options
|
81
|
+
# @option opts [Proc] sort_cols_by Columns are sorted by the value returned by this proc. This proc is provided
|
82
|
+
# the grid :headers, and this proc is handled according to the rules of
|
83
|
+
# Enumerable#sort_by.
|
84
|
+
# @option opts [Proc] sort_rows_by Rows are sorted by the value returned by this proc. This proc is provided
|
85
|
+
# each row in this grid, with the series_name of the row at position zero.
|
86
|
+
# This proc is handled according to the rules of Enumerable#sort_by.
|
87
|
+
# @option opts [Integer] truncate_columns Restrict the total number of columns to a maximum this number
|
88
|
+
# @option opts [Integer] truncate_rows Restrict the total number of rows to a maximum this number
|
89
|
+
# @option opts [String] truncate_remainder_row If the columns are truncated, the 'chopped' columns are summed into
|
90
|
+
# 'the last column'. The value of this option, is used as the label
|
91
|
+
# for that column. Candidly... this feature makes a lot of
|
92
|
+
# assumptions, and probably needs a bit more refinement.
|
93
|
+
# @option opts [TrueClass,FalseClass] switch_rows_columns Rotates the grid, so that series labels and headers are
|
94
|
+
# swapped. All data is preserved under/adjacent to the same series label and
|
95
|
+
# header, in the returned grid. Google offers this option in its GUI, but
|
96
|
+
# doesn't seem to support it via the API. So, we can just do that ourselves
|
97
|
+
# here.
|
98
|
+
# @return [Array<Array<Object>>] A grid of cells, composed of the provided grids, and transformed by the given
|
99
|
+
# parameters.
|
100
|
+
def to_grid(opts = {})
|
101
|
+
# First we'll collect the header row, possibly sorted :
|
102
|
+
if opts[:sort_cols_by]
|
103
|
+
# We collect the data under the column, and feed that into sort_cols_by
|
104
|
+
grid_columns = headers.map do |col|
|
105
|
+
[col] + data.map { |_, values| values[col] }
|
106
|
+
end.sort_by(&opts[:sort_cols_by]).map(&:first)
|
107
|
+
end
|
108
|
+
grid_columns ||= headers
|
109
|
+
|
110
|
+
# Then we collect the non-header rows:
|
111
|
+
grid = data.map { |series, values| [series] + grid_columns.map { |col| values[col] } }
|
112
|
+
|
113
|
+
# Sort those rows, if necessesary:
|
114
|
+
grid.sort_by!(&opts[:sort_rows_by]) if opts[:sort_rows_by]
|
115
|
+
|
116
|
+
# Affix the header row to the top of the grid. Now it's assembled.
|
117
|
+
grid.unshift [keystone] + grid_columns
|
118
|
+
|
119
|
+
# Do we Truncate Rows?
|
120
|
+
if opts[:truncate_rows] && grid.length > opts[:truncate_rows]
|
121
|
+
# We can only have about 26 columns on Google. And, since we're (sometimes)
|
122
|
+
# transposing rows to columns, we have to truncate the rows.
|
123
|
+
|
124
|
+
# NOTE: The 1 is for the truncate_remainder_row, the 'overflow' column
|
125
|
+
chop_length = grid.length - opts[:truncate_rows]
|
126
|
+
|
127
|
+
if chop_length.positive?
|
128
|
+
chopped_rows = grid.pop chop_length
|
129
|
+
truncate_row = chopped_rows.inject([]) do |sum, row|
|
130
|
+
# Starting at the second cell (the first is the series name) merge
|
131
|
+
# the contents of the current row, into the collection cell.
|
132
|
+
row[1...].each_with_index.map do |cell, i|
|
133
|
+
# This rigamarole is mostly to help prevent type issues...
|
134
|
+
if cell
|
135
|
+
sum[i] ? sum[i] + cell : cell
|
136
|
+
else
|
137
|
+
sum[i]
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
grid << ([(opts[:truncate_remainder_row]) || 'Other'] + truncate_row)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Do we Truncate Columns?
|
147
|
+
if opts[:truncate_columns] && grid[0].length > opts[:truncate_columns]
|
148
|
+
# Go through each row, pop off the excess cells, and sum them onto the end
|
149
|
+
grid.each_with_index do |row, i|
|
150
|
+
# The plus one is to make room for the 'Other' column
|
151
|
+
chop_length = row.length - opts[:truncate_columns] + 1
|
152
|
+
|
153
|
+
chopped_cols = row.pop chop_length
|
154
|
+
truncate_cell = if i.zero?
|
155
|
+
opts[:truncate_remainder_row] || 'Other'
|
156
|
+
else
|
157
|
+
chopped_cols.all?(&:nil?) ? nil : chopped_cols.compact.sum
|
158
|
+
end
|
159
|
+
|
160
|
+
row << truncate_cell
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Google offers this option in its GUI, but doesn't seem to support it via
|
165
|
+
# the API. So, we can just do that ourselves:
|
166
|
+
grid = 0.upto(grid[0].length - 1).map { |i| grid.map { |row| row[i] } } if opts[:switch_rows_columns]
|
167
|
+
|
168
|
+
grid
|
169
|
+
end
|
170
|
+
|
171
|
+
private
|
172
|
+
|
173
|
+
def add_columns(headers)
|
174
|
+
@headers += headers.reject { |header| @headers.include? header }
|
175
|
+
end
|
176
|
+
|
177
|
+
# NOTE: that we're assuming value here, is always a commodity. Not sure about
|
178
|
+
# that over time.
|
179
|
+
def add_data(series_name, colname_to_value)
|
180
|
+
@data[series_name] ||= {}
|
181
|
+
|
182
|
+
colname_to_value.each do |colname, value|
|
183
|
+
raise StandardError, 'Unimplemented. How to merge?' if @data[series_name].key? colname
|
184
|
+
|
185
|
+
@data[series_name][colname] = value
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'psych'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
Psych.add_builtin_type('proc') { |_, val| RVGP::Utilities::Yaml::PsychProc.new val }
|
7
|
+
Psych.add_builtin_type('include') { |_, val| RVGP::Utilities::Yaml::PsychInclude.new val }
|
8
|
+
|
9
|
+
module RVGP
|
10
|
+
module Utilities
|
11
|
+
# This class wraps the Psych library, and adds functionality we need, to parse
|
12
|
+
# yaml files.
|
13
|
+
# We mostly added this class because the Psych.add_builtin_type wasn't able to
|
14
|
+
# contain state outside it's return values. (which we need, in order to
|
15
|
+
# track include dependencies)
|
16
|
+
class Yaml
|
17
|
+
# This class is needed, in order to capture the dependencies needed to
|
18
|
+
# properly preserve uptodate status in rake builds
|
19
|
+
class PsychInclude
|
20
|
+
attr_reader :path
|
21
|
+
|
22
|
+
# Declare a proc, given the provided proc_as_string code
|
23
|
+
# @param [String] path A relative or absolute path, to include, in the place of this line
|
24
|
+
def initialize(path)
|
25
|
+
@path = path
|
26
|
+
end
|
27
|
+
|
28
|
+
# The contents of this include target.
|
29
|
+
# @param [Array<String>] include_paths A relative or absolute path, to include, in case our path is relative
|
30
|
+
# and we need to load it from a relative location. These paths are
|
31
|
+
# scanned for the include, in the relative order of their place in the
|
32
|
+
# array.
|
33
|
+
# @return [Hash] The contents of the target of this include. Keys are symbolized, and permitted_classes include
|
34
|
+
# Date, and Symbol
|
35
|
+
def contents(include_paths)
|
36
|
+
content_path = path if Pathname.new(path).absolute?
|
37
|
+
content_path ||= include_paths.map { |p| [p.chomp('/'), path].join('/') }.find do |p|
|
38
|
+
File.readable? p
|
39
|
+
end
|
40
|
+
|
41
|
+
unless content_path
|
42
|
+
raise StandardError, format('Unable to find %<path>s in any of the provided paths: %<paths>s',
|
43
|
+
path: path.inspect, paths: include_paths.inspect)
|
44
|
+
end
|
45
|
+
|
46
|
+
Psych.safe_load_file content_path, symbolize_names: true, permitted_classes: [Date, Symbol]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# This class wraps proc types, in the yaml, and offers us the ability to execute
|
51
|
+
# the code in these blocks.
|
52
|
+
class PsychProc
|
53
|
+
# Declare a proc, given the provided proc_as_string code
|
54
|
+
# @param [String] proc_as_string Ruby code, that this block will call.
|
55
|
+
def initialize(proc_as_string)
|
56
|
+
@block = eval(format('proc { %s }', proc_as_string)) # rubocop:disable Security/Eval
|
57
|
+
end
|
58
|
+
|
59
|
+
# Execute the block, using the provided params as locals
|
60
|
+
# NOTE: We expect symbol to value here
|
61
|
+
# @param params [Hash<Symbol, Object>] local values, available in the proc context. Note that keys
|
62
|
+
# are expected to be symbols.
|
63
|
+
# @return [Object] Whatever the proc returns
|
64
|
+
def call(params = {})
|
65
|
+
# params, here, act as instance variables, since we don't support named
|
66
|
+
# params in the provided string, the way you typically would.
|
67
|
+
@params = params
|
68
|
+
@block.call
|
69
|
+
end
|
70
|
+
|
71
|
+
# @!visibility private
|
72
|
+
def respond_to_missing?(name, _include_private = false)
|
73
|
+
@params&.key?(name.to_sym)
|
74
|
+
end
|
75
|
+
|
76
|
+
# @!visibility private
|
77
|
+
def method_missing(name)
|
78
|
+
respond_to_missing?(name) ? @params[name] : super(name)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
attr_reader :path, :include_paths, :dependencies
|
83
|
+
|
84
|
+
# @param [String] path The full path to the yaml file you wish to parse
|
85
|
+
# @param [Array<String>] include_paths An array of directories, to search, when locating the target of
|
86
|
+
# a "!!include" line
|
87
|
+
def initialize(path, include_paths = nil)
|
88
|
+
@path = path
|
89
|
+
@dependencies = []
|
90
|
+
@include_paths = Array(include_paths || File.expand_path(File.dirname(path)))
|
91
|
+
|
92
|
+
vanilla_yaml = Psych.safe_load_file(path, symbolize_names: true, permitted_classes: [Date, Symbol])
|
93
|
+
@yaml = replace_each_in_yaml(vanilla_yaml, PsychInclude) do |psych_inc|
|
94
|
+
@dependencies << psych_inc.path
|
95
|
+
psych_inc.contents @include_paths
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Return the specified attribute, in this yaml file
|
100
|
+
# @param [String] attr The attribute you're looking for
|
101
|
+
# @return [Object] The value of the the provided attribute
|
102
|
+
def [](attr)
|
103
|
+
@yaml&.[](attr)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns true or false, depending on whether the attribute you're looking for, exists in this
|
107
|
+
# yaml file.
|
108
|
+
# @param [String] attr The attribute you're looking for
|
109
|
+
# @return [TrueClass,FalseClass] Whether the key is defined in this file
|
110
|
+
def key?(attr)
|
111
|
+
@yaml&.key? attr
|
112
|
+
end
|
113
|
+
|
114
|
+
alias has_key? key?
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
# This is kind of a goofy function, but, it works
|
119
|
+
def replace_each_in_yaml(obj, of_class, &blk)
|
120
|
+
case obj
|
121
|
+
when Hash
|
122
|
+
obj.transform_values { |v| replace_each_in_yaml v, of_class, &blk }
|
123
|
+
when Array
|
124
|
+
obj.collect { |v| replace_each_in_yaml(v, of_class, &blk) }
|
125
|
+
else
|
126
|
+
obj.is_a?(of_class) ? blk.call(obj) : obj
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|