ynab_convert 1.0.8 → 2.0.3
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/.github/workflows/publish.yml +36 -0
- data/.github/workflows/test.yml +31 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +10 -2
- data/Gemfile.lock +39 -14
- data/Guardfile +1 -29
- data/README.md +82 -7
- 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 +22 -3
- data/ynab_convert.gemspec +4 -0
- metadata +94 -10
- data/.travis.yml +0 -20
- 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 -137
- data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Documents
|
4
|
+
module YNAB4Files
|
5
|
+
# Represents the YNAB4 formatted CSV data for importing into YNAB4
|
6
|
+
class YNAB4File
|
7
|
+
attr_reader :csv_export_options
|
8
|
+
|
9
|
+
def initialize(institution_name:, format: :flows)
|
10
|
+
@format = format
|
11
|
+
@institution_name = institution_name
|
12
|
+
@csv_export_options = {
|
13
|
+
converters: %i[numeric date],
|
14
|
+
force_quotes: true,
|
15
|
+
write_headers: true,
|
16
|
+
headers: headers
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
def update_dates(row)
|
21
|
+
date_index = 0
|
22
|
+
transaction_date = row[date_index]
|
23
|
+
unless transaction_date.is_a?(Date)
|
24
|
+
transaction_date = Date.parse(transaction_date)
|
25
|
+
end
|
26
|
+
|
27
|
+
update_start_date(transaction_date)
|
28
|
+
update_end_date(transaction_date)
|
29
|
+
end
|
30
|
+
|
31
|
+
def filename
|
32
|
+
from_date = @start_date.strftime('%Y%m%d')
|
33
|
+
to_date = @end_date.strftime('%Y%m%d')
|
34
|
+
|
35
|
+
"#{@institution_name.snake_case}_#{from_date}-#{to_date}_ynab4.csv"
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def update_start_date(date)
|
41
|
+
@start_date = date if @start_date.nil? || date < @start_date
|
42
|
+
end
|
43
|
+
|
44
|
+
def update_end_date(date)
|
45
|
+
@end_date = date if @end_date.nil? || date > @end_date
|
46
|
+
end
|
47
|
+
|
48
|
+
def headers
|
49
|
+
base_headers = %w[Date Payee Memo]
|
50
|
+
extra_headers = %w[Outflow Inflow]
|
51
|
+
|
52
|
+
extra_headers = %w[Amount] if @format == :amounts
|
53
|
+
|
54
|
+
base_headers.concat(extra_headers)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Groups Statements and YNAB4File
|
4
|
+
module Documents
|
5
|
+
documents = %w[statement ynab4_file]
|
6
|
+
|
7
|
+
# Load all known Documents
|
8
|
+
documents.each do |d|
|
9
|
+
# Require the base classes first so that its children can find the parent
|
10
|
+
# class since files are otherwise loaded in alphabetical order
|
11
|
+
require File.join(__dir__, 'documents', "#{d}s", "#{d}.rb")
|
12
|
+
|
13
|
+
Dir[File.join(__dir__, 'documents', "#{d}s", '*.rb')].sort.each do |file|
|
14
|
+
require file
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/ynab_convert/logger.rb
CHANGED
@@ -0,0 +1,24 @@
|
|
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
|
+
# Example Processor
|
9
|
+
class Example < Processor
|
10
|
+
# @param filepath [String] path to the CSV file
|
11
|
+
def initialize(filepath:)
|
12
|
+
transformers = [
|
13
|
+
Transformers::Formatters::Example.new
|
14
|
+
]
|
15
|
+
statement = Documents::Statements::Example.new(filepath: filepath)
|
16
|
+
ynab4_file = Documents::YNAB4Files::YNAB4File.new(
|
17
|
+
format: :flows, institution_name: statement.institution_name
|
18
|
+
)
|
19
|
+
|
20
|
+
super(statement: statement, ynab4_file: ynab4_file, transformers:
|
21
|
+
transformers)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -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
|