ynab_convert 1.0.6 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|