ynab_convert 1.0.6 → 2.0.0
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 +4 -4
- data/.gitignore +5 -0
- data/.rubocop.yml +10 -2
- data/Gemfile.lock +37 -12
- data/Guardfile +1 -29
- data/README.md +76 -5
- data/lib/ynab_convert/api_clients/api_client.rb +24 -0
- data/lib/ynab_convert/api_clients/currency_api.rb +66 -0
- data/lib/ynab_convert/documents/statements/example_statement.rb +16 -0
- data/lib/ynab_convert/documents/statements/n26_statement.rb +24 -0
- data/lib/ynab_convert/documents/statements/statement.rb +39 -0
- data/lib/ynab_convert/documents/statements/ubs_chequing_statement.rb +20 -0
- data/lib/ynab_convert/documents/statements/ubs_credit_statement.rb +19 -0
- data/lib/ynab_convert/documents/statements/wise_statement.rb +17 -0
- data/lib/ynab_convert/documents/ynab4_files/ynab4_file.rb +58 -0
- data/lib/ynab_convert/documents.rb +17 -0
- data/lib/ynab_convert/logger.rb +1 -1
- data/lib/ynab_convert/processors/example_processor.rb +24 -0
- data/lib/ynab_convert/processors/n26_processor.rb +26 -0
- data/lib/ynab_convert/processors/processor.rb +75 -0
- data/lib/ynab_convert/processors/ubs_chequing_processor.rb +21 -0
- data/lib/ynab_convert/processors/ubs_credit_processor.rb +17 -0
- data/lib/ynab_convert/processors/wise_processor.rb +19 -0
- data/lib/ynab_convert/processors.rb +2 -2
- data/lib/ynab_convert/transformers/cleaners/cleaner.rb +17 -0
- data/lib/ynab_convert/transformers/cleaners/n26_cleaner.rb +13 -0
- data/lib/ynab_convert/transformers/cleaners/ubs_chequing_cleaner.rb +98 -0
- data/lib/ynab_convert/transformers/cleaners/ubs_credit_cleaner.rb +45 -0
- data/lib/ynab_convert/transformers/cleaners/wise_cleaner.rb +39 -0
- data/lib/ynab_convert/transformers/enhancers/enhancer.rb +20 -0
- data/lib/ynab_convert/transformers/enhancers/n26_enhancer.rb +74 -0
- data/lib/ynab_convert/transformers/enhancers/wise_enhancer.rb +87 -0
- data/lib/ynab_convert/transformers/formatters/example_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/formatter.rb +91 -0
- data/lib/ynab_convert/transformers/formatters/n26_formatter.rb +19 -0
- data/lib/ynab_convert/transformers/formatters/ubs_chequing_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/ubs_credit_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/wise_formatter.rb +35 -0
- data/lib/ynab_convert/transformers.rb +18 -0
- data/lib/ynab_convert/validators/ynab4_row_validator.rb +83 -0
- data/lib/ynab_convert/validators.rb +9 -0
- data/lib/ynab_convert/version.rb +1 -1
- data/lib/ynab_convert.rb +4 -3
- data/ynab_convert.gemspec +4 -0
- metadata +91 -8
- data/lib/ynab_convert/processor/base.rb +0 -226
- data/lib/ynab_convert/processor/example.rb +0 -124
- data/lib/ynab_convert/processor/n26.rb +0 -70
- data/lib/ynab_convert/processor/revolut.rb +0 -103
- data/lib/ynab_convert/processor/ubs_chequing.rb +0 -115
- data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ynab_convert/documents'
|
4
|
+
require 'ynab_convert/transformers'
|
5
|
+
require 'ynab_convert/processors/processor'
|
6
|
+
|
7
|
+
module Processors
|
8
|
+
# Processor for N26 statements
|
9
|
+
class N26 < Processor
|
10
|
+
# @param filepath [String] path to the CSV file
|
11
|
+
def initialize(filepath:)
|
12
|
+
transformers = [
|
13
|
+
Transformers::Cleaners::N26.new,
|
14
|
+
Transformers::Formatters::N26.new,
|
15
|
+
Transformers::Enhancers::N26.new
|
16
|
+
]
|
17
|
+
statement = Documents::Statements::N26.new(filepath: filepath)
|
18
|
+
ynab4_file = Documents::YNAB4Files::YNAB4File.new(format: :amounts,
|
19
|
+
institution_name:
|
20
|
+
statement.institution_name)
|
21
|
+
|
22
|
+
super(statement: statement, ynab4_file: ynab4_file, transformers:
|
23
|
+
transformers)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ynab_convert/documents'
|
4
|
+
require 'ynab_convert/validators'
|
5
|
+
require 'csv'
|
6
|
+
require 'fileutils'
|
7
|
+
|
8
|
+
module Processors
|
9
|
+
# A processor instantiates the Documents and Transformers required to turn a
|
10
|
+
# Statement into a YNAB4File
|
11
|
+
class Processor
|
12
|
+
# @param statement [Documents::Statement] The CSV statement to process
|
13
|
+
# @param ynab4_file [Documents::YNAB4Files::YNAB4File] An instance of
|
14
|
+
# YNAB4File
|
15
|
+
# @param converters [Hash<Symbol, Proc>] A hash of converters to process
|
16
|
+
# each Statement row. The key is the name of the custom converter.
|
17
|
+
# The Proc receives the cell's content as a string and returns the
|
18
|
+
# converted value. See CSV::Converters.
|
19
|
+
# @param transformers [Array<Transformers::Transformer>] The Transformers
|
20
|
+
# to run in sequense
|
21
|
+
def initialize(statement:, ynab4_file:, transformers:, converters: {})
|
22
|
+
@statement = statement
|
23
|
+
@transformers = transformers
|
24
|
+
@validators = [::Validators::YNAB4Row]
|
25
|
+
@uid = rand(36**8).to_s(36)
|
26
|
+
@ynab4_file = ynab4_file
|
27
|
+
register_converters(converters)
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_ynab!
|
31
|
+
convert
|
32
|
+
rename_temp_file
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def convert
|
38
|
+
CSV.open(temp_filepath, 'wb',
|
39
|
+
**@ynab4_file.csv_export_options) do |ynab4_csv|
|
40
|
+
CSV.foreach(@statement.filepath, 'rb',
|
41
|
+
**@statement.csv_import_options) do |statement_row|
|
42
|
+
transformed_row = @transformers.reduce(statement_row) do |row, t|
|
43
|
+
t.run(row)
|
44
|
+
end
|
45
|
+
|
46
|
+
row_valid = @validators.reduce(true) do |is_valid, v|
|
47
|
+
is_valid && v.valid?(transformed_row)
|
48
|
+
end
|
49
|
+
|
50
|
+
if row_valid
|
51
|
+
@ynab4_file.update_dates(transformed_row)
|
52
|
+
ynab4_csv << transformed_row
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def register_converters(converters)
|
59
|
+
converters.each do |name, block|
|
60
|
+
CSV::Converters[name] = block
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def temp_filepath
|
65
|
+
basename = File.basename(@statement.filepath, '.csv')
|
66
|
+
financial_institution = @statement.institution_name
|
67
|
+
|
68
|
+
"#{basename}_#{financial_institution.snake_case}_#{@uid}_ynab4.csv"
|
69
|
+
end
|
70
|
+
|
71
|
+
def rename_temp_file
|
72
|
+
FileUtils.mv(temp_filepath, @ynab4_file.filename)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processors
|
4
|
+
# UBS Switzerland Chequing accounts processor
|
5
|
+
class UBSChequing < Processor
|
6
|
+
# @param filepath [String] path to the CSV file
|
7
|
+
def initialize(filepath:)
|
8
|
+
transformers = [
|
9
|
+
Transformers::Cleaners::UBSChequing.new,
|
10
|
+
Transformers::Formatters::UBSChequing.new
|
11
|
+
]
|
12
|
+
statement = Documents::Statements::UBSChequing.new(filepath: filepath)
|
13
|
+
ynab4_file_options = { format: :flows,
|
14
|
+
institution_name: statement.institution_name }
|
15
|
+
ynab4_file = Documents::YNAB4Files::YNAB4File.new(ynab4_file_options)
|
16
|
+
|
17
|
+
super(statement: statement, ynab4_file: ynab4_file, transformers:
|
18
|
+
transformers)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processors
|
4
|
+
# UBS Switzerland Credit Card accounts processor
|
5
|
+
class UBSCredit < Processor
|
6
|
+
def initialize(filepath:)
|
7
|
+
statement = Documents::Statements::UBSCredit.new(filepath: filepath)
|
8
|
+
ynab4_file_options = { institution_name: statement.institution_name }
|
9
|
+
ynab4_file = Documents::YNAB4Files::YNAB4File.new(ynab4_file_options)
|
10
|
+
transformers = [Transformers::Cleaners::UBSCredit.new,
|
11
|
+
Transformers::Formatters::UBSCredit.new]
|
12
|
+
|
13
|
+
super(statement: statement, ynab4_file: ynab4_file, transformers:
|
14
|
+
transformers)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processors
|
4
|
+
# Wise card accounts processor
|
5
|
+
class Wise < Processor
|
6
|
+
def initialize(filepath:)
|
7
|
+
statement = Documents::Statements::Wise.new(filepath: filepath)
|
8
|
+
ynab4_file = Documents::YNAB4Files::YNAB4File.new(
|
9
|
+
institution_name: statement.institution_name, format: :amounts
|
10
|
+
)
|
11
|
+
transformers = [Transformers::Cleaners::Wise.new,
|
12
|
+
Transformers::Formatters::Wise.new,
|
13
|
+
Transformers::Enhancers::Wise.new]
|
14
|
+
|
15
|
+
super(statement: statement, ynab4_file: ynab4_file, transformers:
|
16
|
+
transformers)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# Base processor must be loaded first as all others inherit from it
|
4
|
-
require 'ynab_convert/processor
|
4
|
+
require 'ynab_convert/processors/processor'
|
5
5
|
|
6
6
|
# Load all known processors
|
7
|
-
Dir[File.join(__dir__, '
|
7
|
+
Dir[File.join(__dir__, 'processors', '*.rb')].sort.each { |file| require file }
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Cleaners
|
5
|
+
# A Cleaner tidies up the Statement fields. For instance, it removes junk
|
6
|
+
# from the transaction descriptions/payee name, combines several
|
7
|
+
# columns into one to build the payee name, etc.
|
8
|
+
class Cleaner
|
9
|
+
# Cleans up a row
|
10
|
+
# @param row [CSV::Row] The row to parse
|
11
|
+
# @return [CSV::Row] The cleaned up row
|
12
|
+
def run(_row)
|
13
|
+
raise NotImplementedError, :run
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Cleaners
|
5
|
+
# UBS Switzerland Chequing accounts cleaner
|
6
|
+
class UBSChequing < Cleaner
|
7
|
+
HEADERS = {
|
8
|
+
date: 9,
|
9
|
+
payee_line1: 12,
|
10
|
+
payee_line2: 13,
|
11
|
+
payee_line3: 14,
|
12
|
+
outflow: 18,
|
13
|
+
inflow: 19
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def run(row)
|
17
|
+
date = parse_transaction_date(row[HEADERS[:date]])
|
18
|
+
payee = clean_payee_lines(row)
|
19
|
+
outflow = parse_amount(row[HEADERS[:outflow]])
|
20
|
+
inflow = parse_amount(row[HEADERS[:inflow]])
|
21
|
+
|
22
|
+
cleaned_row = row.dup
|
23
|
+
cleaned_row[HEADERS[:date]] = date
|
24
|
+
# Put all the relevant payee data in the first line and empty the other
|
25
|
+
# two lines
|
26
|
+
cleaned_row[HEADERS[:payee_line1]] = payee
|
27
|
+
cleaned_row[HEADERS[:payee_line2]] = ''
|
28
|
+
cleaned_row[HEADERS[:payee_line3]] = ''
|
29
|
+
cleaned_row[HEADERS[:outflow]] = outflow
|
30
|
+
cleaned_row[HEADERS[:inflow]] = inflow
|
31
|
+
|
32
|
+
cleaned_row
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def parse_transaction_date(date)
|
38
|
+
# Transaction dates are dd.mm.YYYY which Date#parse understands, but
|
39
|
+
# the CSV::Converter[:date] doesn't recognize it as it's not looking
|
40
|
+
# for dot separators.
|
41
|
+
return Date.parse(date) unless date.is_a?(Date) || date.nil?
|
42
|
+
|
43
|
+
date
|
44
|
+
end
|
45
|
+
|
46
|
+
def parse_amount(amount)
|
47
|
+
unless amount.nil? || amount.is_a?(Numeric)
|
48
|
+
return amount.delete("'").to_f
|
49
|
+
end
|
50
|
+
|
51
|
+
amount
|
52
|
+
end
|
53
|
+
|
54
|
+
def clean_payee_lines(row)
|
55
|
+
# Some lines are just bogus without any payee info. These will get
|
56
|
+
# weeded out down the line by the validators.
|
57
|
+
return row if row[HEADERS[:payee_line1]].nil? ||
|
58
|
+
row[HEADERS[:payee_line1]].empty?
|
59
|
+
|
60
|
+
# Transaction description is spread over 3 columns.
|
61
|
+
# There are two types of entries:
|
62
|
+
# 1. only the first column contains data
|
63
|
+
# 2. all three columns contain data, most of it junk, with only cols 2
|
64
|
+
# and 3 having meaningful data
|
65
|
+
# Cleaning them up means dropping the first column if there is anything
|
66
|
+
# in the other columns;
|
67
|
+
# Then removing the rest of the junk appended after the worthwhile data;
|
68
|
+
# Finally removing the CARD 00000000-0 0000 at the beginning of debit
|
69
|
+
# card payment entries
|
70
|
+
raw_payee_line = [row[HEADERS[:payee_line2]],
|
71
|
+
row[HEADERS[:payee_line3]]].join(' ')
|
72
|
+
if row[HEADERS[:payee_line2]].nil?
|
73
|
+
raw_payee_line = row[HEADERS[:payee_line1]]
|
74
|
+
end
|
75
|
+
|
76
|
+
# UBS thought wise to append a bunch of junk information after the
|
77
|
+
# transaction details within the third description field. *Most* of
|
78
|
+
# this junk starts after the meaningful data and starts with ", OF", ",
|
79
|
+
# ON", ", ESR", ", QRR", two digits then five groups of five digits
|
80
|
+
# then ", TN" so we discard it; YNAB4 being unable to automatically
|
81
|
+
# categorize new transactions at the same store/payee if the payee
|
82
|
+
# always looks different (thanks to the variable nature of the appended
|
83
|
+
# junk).
|
84
|
+
|
85
|
+
# rubocop:disable Layout/LineLength
|
86
|
+
junk_desc_regex = /,? (O[FN]|ESR|QRR|\d{2} \d{5} \d{5} \d{5} \d{5} \d{5}, TN).*/
|
87
|
+
# rubocop:enable Layout/LineLength
|
88
|
+
|
89
|
+
# Of course, it wouldn't be complete with more junk information at the
|
90
|
+
# beginning of *some* lines (debit card payments) in the following
|
91
|
+
# form: "CARD 00000000-0 0000"
|
92
|
+
debit_card_junk_regex = /CARD \d{8}-\d \d{4} /
|
93
|
+
|
94
|
+
raw_payee_line.sub(junk_desc_regex, '').sub(debit_card_junk_regex, '')
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Cleaners
|
5
|
+
# UBS Switzerland Credit Card accounts cleaner
|
6
|
+
class UBSCredit < Cleaner
|
7
|
+
HEADERS = {
|
8
|
+
date: 3,
|
9
|
+
payee: 4,
|
10
|
+
outflow: 10,
|
11
|
+
inflow: 11
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
def run(row)
|
15
|
+
date = parse_transaction_date(row[HEADERS[:date]])
|
16
|
+
outflow = parse_amount(row[HEADERS[:outflow]])
|
17
|
+
inflow = parse_amount(row[HEADERS[:inflow]])
|
18
|
+
|
19
|
+
cleaned_row = row.dup
|
20
|
+
cleaned_row[HEADERS[:date]] = date
|
21
|
+
cleaned_row[HEADERS[:outflow]] = outflow
|
22
|
+
cleaned_row[HEADERS[:inflow]] = inflow
|
23
|
+
|
24
|
+
cleaned_row
|
25
|
+
end
|
26
|
+
|
27
|
+
def parse_transaction_date(date)
|
28
|
+
# Transaction dates are dd.mm.YYYY which Date#parse understands, but
|
29
|
+
# the CSV::Converter[:date] doesn't recognize it as it's not looking
|
30
|
+
# for dot separators.
|
31
|
+
return Date.parse(date) unless date.is_a?(Date) || date.nil?
|
32
|
+
|
33
|
+
date
|
34
|
+
end
|
35
|
+
|
36
|
+
def parse_amount(amount)
|
37
|
+
unless amount.nil? || amount.is_a?(Numeric)
|
38
|
+
return amount.delete("'").to_f
|
39
|
+
end
|
40
|
+
|
41
|
+
amount
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Cleaners
|
5
|
+
# Wise card accounts cleaner
|
6
|
+
class Wise < Cleaner
|
7
|
+
def run(row)
|
8
|
+
date_index = 1
|
9
|
+
payee_index = 13
|
10
|
+
cleaned_row = row.dup
|
11
|
+
date = parse_date(cleaned_row[date_index])
|
12
|
+
payee = clean_payee(cleaned_row[payee_index])
|
13
|
+
|
14
|
+
cleaned_row[date_index] = date
|
15
|
+
cleaned_row[payee_index] = payee
|
16
|
+
|
17
|
+
cleaned_row
|
18
|
+
end
|
19
|
+
|
20
|
+
# turn String "dd-mm-YYYY" into a Date since the
|
21
|
+
# CSV::Transformers[:date] doesn't recognize the dd-mm-YYYY format
|
22
|
+
# @param date [String] the date string to parse (format "dd-mm-YYYY")
|
23
|
+
# @return Date the parsed date string
|
24
|
+
def parse_date(date)
|
25
|
+
Date.parse(date)
|
26
|
+
end
|
27
|
+
|
28
|
+
# The payee data can include some junk (mostly for PayPal/Ebay
|
29
|
+
# transactions)
|
30
|
+
# @param payee [String] the payee string to clean
|
31
|
+
# @return String the payee string without the junk
|
32
|
+
def clean_payee(payee)
|
33
|
+
return payee if payee.nil?
|
34
|
+
|
35
|
+
payee.gsub(/O\*\d{2}-\d{5}-\d{5}\s/, '')
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Enhancers
|
5
|
+
# An Enhancer parses YNAB4 rows (Date, Payee, Memo, Amount or Inflow and
|
6
|
+
# Outflow) and augments the data therein.
|
7
|
+
# A typical case would be converting from one currency to the user's YNAB
|
8
|
+
# base currency when the Statement is in a different currency (see
|
9
|
+
# n26_enhancer.rb for an example)
|
10
|
+
class Enhancer
|
11
|
+
def initialize; end
|
12
|
+
|
13
|
+
# @param _row [CSV::Row] The row to enhance
|
14
|
+
# @return [CSV::Row] The enhanced row
|
15
|
+
def run(_row)
|
16
|
+
raise NotImplementedError, :run
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ynab_convert/transformers/enhancers/enhancer'
|
4
|
+
require 'ynab_convert/api_clients/currency_api'
|
5
|
+
|
6
|
+
module Transformers
|
7
|
+
module Enhancers
|
8
|
+
# Converts amounts from EUR to CHF
|
9
|
+
class N26 < Enhancer
|
10
|
+
def initialize
|
11
|
+
@api_client = APIClients::CurrencyAPI.new
|
12
|
+
super()
|
13
|
+
end
|
14
|
+
|
15
|
+
# @param row [CSV::Row] The YNAB4 formatted row to enhance
|
16
|
+
# @return [CSV::Row] A YNAB4 formatted row with amounts converted
|
17
|
+
# @note The currency is hardcoded to CHF for now
|
18
|
+
# @note Takes a YNAB4 formatted CSV::Row, with the transaction's currency
|
19
|
+
# in the Memo field
|
20
|
+
def run(row)
|
21
|
+
amount_index = 3
|
22
|
+
amount = row[amount_index]
|
23
|
+
|
24
|
+
# Transaction currency should be in the memo field
|
25
|
+
base_currency = row[2].to_sym
|
26
|
+
|
27
|
+
date = row[0]
|
28
|
+
|
29
|
+
# TODO: Implement a config file for currencies
|
30
|
+
converted_amount = convert_amount(amount: amount,
|
31
|
+
base_currency: base_currency,
|
32
|
+
target_currency: :chf,
|
33
|
+
date: date)
|
34
|
+
|
35
|
+
enhanced_row = row.dup
|
36
|
+
enhanced_row[amount_index] = converted_amount
|
37
|
+
# Put original amount and currency in Memo
|
38
|
+
memo_line = 'Original amount: '\
|
39
|
+
"#{format('%<amount>.2f', amount: amount)} #{base_currency}"
|
40
|
+
enhanced_row[2] = memo_line
|
41
|
+
enhanced_row
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# @param base_currency [Symbol] The ISO symbol of the amount's
|
47
|
+
# currency (base currency)
|
48
|
+
# @param target_currency [Symbol] The ISO symbol of the currency to
|
49
|
+
# convert the amount to (target currency)
|
50
|
+
# @param date [Date] The date on which to fetch the rate for conversion
|
51
|
+
# @return [Float] The conversion rate for amount_currency into CHF
|
52
|
+
def get_rate_for_date(base_currency:, target_currency:, date:)
|
53
|
+
rates = @api_client.historical(base_currency: base_currency, date: date)
|
54
|
+
rates[target_currency]
|
55
|
+
end
|
56
|
+
|
57
|
+
# @param base_currency [Symbol] The ISO symbol of the amount's
|
58
|
+
# currency
|
59
|
+
# @param target_currency [Symbol] The ISO symbol of the currency to
|
60
|
+
# convert the amount to (target currency)
|
61
|
+
# @param amount [Numeric] The amount in amount_currency to convert
|
62
|
+
# @param date [Date] The date on which to fetch the rate for conversion
|
63
|
+
# @return [Numeric] The converted amount
|
64
|
+
def convert_amount(amount:, base_currency:, target_currency:, date:)
|
65
|
+
rate = get_rate_for_date(base_currency: base_currency,
|
66
|
+
target_currency: target_currency,
|
67
|
+
date: date)
|
68
|
+
|
69
|
+
# format('%<converted>.2f', converted: amount * rate)
|
70
|
+
(amount * rate).round(2)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Enhancers
|
5
|
+
# Wise card accounts enhancer
|
6
|
+
class Wise < Enhancer
|
7
|
+
def initialize
|
8
|
+
@api_client = APIClients::CurrencyAPI.new
|
9
|
+
@indices = { date: 0, amount: 3, memo: 2 }
|
10
|
+
|
11
|
+
super()
|
12
|
+
end
|
13
|
+
|
14
|
+
def run(ynab_row)
|
15
|
+
amount = ynab_row[@indices[:amount]]
|
16
|
+
date = ynab_row[@indices[:date]]
|
17
|
+
metadata = deserialize_metadata(ynab_row[@indices[:memo]])
|
18
|
+
enhanced_row = ynab_row.dup
|
19
|
+
|
20
|
+
# Some transactions, like topups, don't have require conversion
|
21
|
+
unless metadata[:original_amount].nil?
|
22
|
+
converted_amount = convert_amount(
|
23
|
+
amount: amount,
|
24
|
+
base_currency: metadata[:amount_currency],
|
25
|
+
target_currency: :chf,
|
26
|
+
date: date
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Inlude the original transaction amount in the original currency in
|
31
|
+
# the Memo field if conversion was performed
|
32
|
+
unless converted_amount.nil?
|
33
|
+
enhanced_row[@indices[:amount]] = converted_amount
|
34
|
+
# Put original amount and currency in Memo
|
35
|
+
enhanced_row[@indices[:memo]] = 'Original amount: '\
|
36
|
+
"#{metadata[:original_amount]}"
|
37
|
+
end
|
38
|
+
|
39
|
+
# If not, clear the metadata from the Memo field
|
40
|
+
enhanced_row[@indices[:memo]] = '' if converted_amount.nil?
|
41
|
+
|
42
|
+
enhanced_row
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# @param memo [String] string to deserialize (format is
|
48
|
+
# `<amount_currency>,<original_amount>`, as formatted by the
|
49
|
+
# wise_formatter.rb)
|
50
|
+
def deserialize_metadata(memo)
|
51
|
+
# metadata is `<amount_currency>,<original_amount>`
|
52
|
+
split = memo.split(',')
|
53
|
+
|
54
|
+
# amount_currency is a String but the CurrencyAPI needs it to be a
|
55
|
+
# Symbol
|
56
|
+
{ amount_currency: split[0].to_sym, original_amount: split[1] }
|
57
|
+
end
|
58
|
+
|
59
|
+
# @param base_currency [Symbol] The ISO symbol of the amount's
|
60
|
+
# currency (base currency)
|
61
|
+
# @param target_currency [Symbol] The ISO symbol of the currency to
|
62
|
+
# convert the amount to (target currency)
|
63
|
+
# @param date [Date] The date on which to fetch the rate for conversion
|
64
|
+
# @return [Float] The conversion rate for amount_currency into CHF
|
65
|
+
def get_rate_for_date(base_currency:, target_currency:, date:)
|
66
|
+
rates = @api_client.historical(base_currency: base_currency, date: date)
|
67
|
+
rates[target_currency]
|
68
|
+
end
|
69
|
+
|
70
|
+
# @param base_currency [Symbol] The ISO symbol of the amount's
|
71
|
+
# currency
|
72
|
+
# @param target_currency [Symbol] The ISO symbol of the currency to
|
73
|
+
# convert the amount to (target currency)
|
74
|
+
# @param amount [Numeric] The amount in amount_currency to convert
|
75
|
+
# @param date [Date] The date on which to fetch the rate for conversion
|
76
|
+
# @return [Numeric] The converted amount
|
77
|
+
def convert_amount(amount:, base_currency:, target_currency:, date:)
|
78
|
+
rate = get_rate_for_date(base_currency: base_currency,
|
79
|
+
target_currency: target_currency,
|
80
|
+
date: date)
|
81
|
+
|
82
|
+
# format('%<converted>.2f', converted: amount * rate)
|
83
|
+
(amount * rate).round(2)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Transformers
|
4
|
+
module Formatters
|
5
|
+
# Formats Statements rows into YNAB4 rows (Date, Payee, Memo, Amount or
|
6
|
+
# Outflow and Inflow.)
|
7
|
+
class Formatter
|
8
|
+
# @param [Hash] headers_indices the indices at which to find each
|
9
|
+
# header's name
|
10
|
+
# @option headers_indices [Array<Numeric>] :date transaction date
|
11
|
+
# @option headers_indices [Array<Numeric>] :payee transaction
|
12
|
+
# payee/description
|
13
|
+
# @option headers_indices [Array<Numeric>] :memo transaction memo or
|
14
|
+
# currency if currency conversion will be performed
|
15
|
+
# @option headers_indices [Array<Numeric>] :amount transaction amount (if
|
16
|
+
# Statement is using the :amounts format)
|
17
|
+
# @option headers_indices [Array<Numeric>] :outflow transaction outflow
|
18
|
+
# (if using the :flows format)
|
19
|
+
# @option headers_indices [Array<Numeric>] :inflow transaction inflow (if
|
20
|
+
# using the :flows format)
|
21
|
+
def initialize(**headers_indices)
|
22
|
+
default_values = {
|
23
|
+
memo: [] # The Memo field tends to be empty for most institutions
|
24
|
+
}
|
25
|
+
|
26
|
+
@format = :flows
|
27
|
+
unless headers_indices[:amount].nil? || headers_indices[:amount].empty?
|
28
|
+
@format = :amounts
|
29
|
+
end
|
30
|
+
@headers_indices = default_values.merge(headers_indices)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Turns CSV rows into YNAB4 rows (Date, Payee, Memo, Amount or Outflow and
|
34
|
+
# Inflow)
|
35
|
+
# @param row [CSV::Row] The CSV row to parse
|
36
|
+
# @return [Array<String>] The YNAB4 formatted row
|
37
|
+
def run(row)
|
38
|
+
ynab_row = [date(row), payee(row), memo(row)]
|
39
|
+
|
40
|
+
if @format == :amounts
|
41
|
+
ynab_row << amount(row)
|
42
|
+
else
|
43
|
+
ynab_row << outflow(row)
|
44
|
+
ynab_row << inflow(row)
|
45
|
+
end
|
46
|
+
|
47
|
+
ynab_row
|
48
|
+
end
|
49
|
+
|
50
|
+
# Processes columns for each row. Based on the method name that is called,
|
51
|
+
# it will extract the corresponding column (field).
|
52
|
+
# @note In more complex cases, some heuristics are required to format some
|
53
|
+
# of the columns. In that case, any of the aliased #field methods
|
54
|
+
# (#date, #payee, #memo, #amount, #outflow, #inflow) can be
|
55
|
+
# overridden in the child.
|
56
|
+
# @param row [CSV::Row] The row to process
|
57
|
+
# @return [String] The corresponding field(s)
|
58
|
+
def field(row)
|
59
|
+
# Figure out the aliased name the method was called with, to derive
|
60
|
+
# which field to return from the row.
|
61
|
+
requested_field = __callee__.to_sym
|
62
|
+
assembled_field = @headers_indices[requested_field].reduce([]) do
|
63
|
+
|fields_data, i|
|
64
|
+
fields_data << row[i]
|
65
|
+
end
|
66
|
+
|
67
|
+
# Avoid turning Dates and Numerics back to strings
|
68
|
+
# If the assembled_field isn't a composite from several Statement
|
69
|
+
# fields, there is no need to join(' ') and turn it into a String
|
70
|
+
formatted_field = assembled_field[0]
|
71
|
+
if assembled_field.length > 1
|
72
|
+
formatted_field = assembled_field.join(' ')
|
73
|
+
end
|
74
|
+
|
75
|
+
# Avoid "nil" values in the output
|
76
|
+
return '' if formatted_field.nil?
|
77
|
+
|
78
|
+
formatted_field
|
79
|
+
end
|
80
|
+
|
81
|
+
# Create alias names for the field method. This allows the function to
|
82
|
+
# figure out which field to extract from its method name.
|
83
|
+
alias date field
|
84
|
+
alias payee field
|
85
|
+
alias memo field
|
86
|
+
alias amount field
|
87
|
+
alias outflow field
|
88
|
+
alias inflow field
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|