ynab_convert 0.1.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +18 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +15 -0
- data/.travis.yml +18 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +135 -0
- data/Guardfile +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +65 -0
- data/Rakefile +40 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/bin/ynab_convert +29 -0
- data/lib/core_extensions/string.rb +17 -0
- data/lib/slop/symbol.rb +12 -0
- data/lib/ynab_convert/error.rb +6 -0
- data/lib/ynab_convert/logger.rb +14 -0
- data/lib/ynab_convert/processor/base.rb +171 -0
- data/lib/ynab_convert/processor/example.rb +124 -0
- data/lib/ynab_convert/processor/revolut.rb +103 -0
- data/lib/ynab_convert/processor/ubs_chequing.rb +101 -0
- data/lib/ynab_convert/processor/ubs_credit.rb +83 -0
- data/lib/ynab_convert/processors.rb +7 -0
- data/lib/ynab_convert/version.rb +5 -0
- data/lib/ynab_convert.rb +125 -0
- data/ynab_convert.gemspec +52 -0
- metadata +260 -0
@@ -0,0 +1,171 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'core_extensions/string'
|
4
|
+
require 'csv'
|
5
|
+
require 'ynab_convert/logger'
|
6
|
+
|
7
|
+
module Processor
|
8
|
+
# Base class for a Processor, all processors must inherit from it
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
10
|
+
class Base
|
11
|
+
include YnabLogger
|
12
|
+
include CoreExtensions::String::Inflections
|
13
|
+
|
14
|
+
attr_reader :loader_options
|
15
|
+
|
16
|
+
# @option opts [String] :file Path to the CSV file to process
|
17
|
+
def initialize(opts)
|
18
|
+
logger.debug "Initializing processor with options: `#{opts.to_h}'"
|
19
|
+
raise ::Errno::ENOENT unless File.exist? opts[:file]
|
20
|
+
|
21
|
+
@file = opts[:file]
|
22
|
+
@headers = { transaction_date: nil, payee: nil, debit: nil, credit: nil }
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_ynab!
|
26
|
+
begin
|
27
|
+
convert!
|
28
|
+
rename_file
|
29
|
+
rescue YnabConvert::Error
|
30
|
+
invalid_csv_file
|
31
|
+
end
|
32
|
+
ensure
|
33
|
+
logger.debug "Deleting temp file `#{temp_filename}'"
|
34
|
+
delete_temp_csv
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
attr_accessor :statement_from, :statement_to, :headers
|
40
|
+
|
41
|
+
def inflow_or_outflow_missing?(row)
|
42
|
+
inflow_index = 3
|
43
|
+
outflow_index = 4
|
44
|
+
# If there is neither inflow and outflow values, or their value is 0,
|
45
|
+
# then the row is not valid to YNAB4
|
46
|
+
(row[inflow_index].nil? || row[inflow_index].empty? ||
|
47
|
+
row[inflow_index] == '0.00') &&
|
48
|
+
(row[outflow_index].nil? || row[outflow_index].empty? ||
|
49
|
+
row[outflow_index] == '0.00')
|
50
|
+
end
|
51
|
+
|
52
|
+
def skip_row(row)
|
53
|
+
logger.debug "Found empty row, skipping it: #{row.to_h}"
|
54
|
+
throw :skip_row
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete_temp_csv
|
58
|
+
FileUtils.remove_file temp_filename, force: true
|
59
|
+
end
|
60
|
+
|
61
|
+
def transaction_date_missing?(ynab_row)
|
62
|
+
ynab_row[0].nil? || [0].empty?
|
63
|
+
end
|
64
|
+
|
65
|
+
def extract_transaction_date(ynab_row)
|
66
|
+
transaction_date_index = 0
|
67
|
+
ynab_row[transaction_date_index]
|
68
|
+
end
|
69
|
+
|
70
|
+
def record_statement_interval_dates(ynab_row)
|
71
|
+
transaction_date_index = 0
|
72
|
+
date = Date.parse(ynab_row[transaction_date_index])
|
73
|
+
|
74
|
+
if date_is_further_away?(date)
|
75
|
+
logger.debug "Replacing statement_from `#{statement_from.inspect}' "\
|
76
|
+
"with `#{date}'"
|
77
|
+
self.statement_from = date
|
78
|
+
end
|
79
|
+
# rubocop:disable Style/GuardClause
|
80
|
+
if date_is_more_recent?(date)
|
81
|
+
logger.debug "Replacing statement_to `#{statement_to.inspect}' with "\
|
82
|
+
"`#{date}'"
|
83
|
+
self.statement_to = date
|
84
|
+
end
|
85
|
+
# rubocop:enable Style/GuardClause
|
86
|
+
end
|
87
|
+
|
88
|
+
def date_is_more_recent?(date)
|
89
|
+
statement_to.nil? || statement_to < date
|
90
|
+
end
|
91
|
+
|
92
|
+
def date_is_further_away?(date)
|
93
|
+
statement_from.nil? || statement_from > date
|
94
|
+
end
|
95
|
+
|
96
|
+
def convert!
|
97
|
+
logger.debug "Will write to `#{temp_filename}'"
|
98
|
+
|
99
|
+
CSV.open(temp_filename, 'wb', output_options) do |converted|
|
100
|
+
CSV.foreach(@file, 'rb', loader_options) do |row|
|
101
|
+
logger.debug "Parsing row: `#{row.to_h}'"
|
102
|
+
# Some rows don't contain valid or useful data
|
103
|
+
catch :skip_row do
|
104
|
+
extract_header_names(row)
|
105
|
+
ynab_row = transformers(row)
|
106
|
+
if inflow_or_outflow_missing?(ynab_row) ||
|
107
|
+
transaction_date_missing?(ynab_row)
|
108
|
+
logger.debug 'Empty row, skipping it'
|
109
|
+
skip_row(row)
|
110
|
+
end
|
111
|
+
converted << ynab_row
|
112
|
+
record_statement_interval_dates(ynab_row)
|
113
|
+
end
|
114
|
+
|
115
|
+
logger.debug 'Done converting'
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def rename_file
|
121
|
+
File.rename(temp_filename, output_filename)
|
122
|
+
logger.debug "Renamed temp file `#{temp_filename}' to "\
|
123
|
+
"`#{output_filename}'"
|
124
|
+
end
|
125
|
+
|
126
|
+
def invalid_csv_file
|
127
|
+
raise YnabConvert::Error, "Unable to parse file `#{@file}'. Is it a "\
|
128
|
+
"valid CSV file from #{@institution_name}?"
|
129
|
+
end
|
130
|
+
|
131
|
+
def file_uid
|
132
|
+
@file_uid ||= rand(36**8).to_s(36)
|
133
|
+
end
|
134
|
+
|
135
|
+
def temp_filename
|
136
|
+
"#{File.basename(@file, '.csv')}_#{@institution_name.snake_case}_"\
|
137
|
+
"#{file_uid}_ynab4.csv"
|
138
|
+
end
|
139
|
+
|
140
|
+
def output_filename
|
141
|
+
# If the file contained no parsable CSV data, from and to dates will be
|
142
|
+
# nil.
|
143
|
+
# This is to avoid a NoMethodError on NilClass.
|
144
|
+
raise YnabConvert::Error if statement_from.nil? || statement_to.nil?
|
145
|
+
|
146
|
+
from = statement_from.strftime('%Y%m%d')
|
147
|
+
to = statement_to.strftime('%Y%m%d')
|
148
|
+
|
149
|
+
"#{File.basename(@file, '.csv')}_#{@institution_name.snake_case}_"\
|
150
|
+
"#{from}-#{to}_ynab4.csv"
|
151
|
+
end
|
152
|
+
|
153
|
+
def ynab_headers
|
154
|
+
%w[Date Payee Memo Outflow Inflow]
|
155
|
+
end
|
156
|
+
|
157
|
+
def output_options
|
158
|
+
{
|
159
|
+
converters: %i[numeric date],
|
160
|
+
force_quotes: true,
|
161
|
+
write_headers: true,
|
162
|
+
headers: ynab_headers
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
def transformers
|
167
|
+
raise NotImplementedError, :transformers
|
168
|
+
end
|
169
|
+
end
|
170
|
+
# rubocop:enable Metrics/ClassLength
|
171
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processor
|
4
|
+
# An example of how to implement a custom processor
|
5
|
+
# Processes CSV files with this format:
|
6
|
+
# <<~ROWS
|
7
|
+
# "Date","Payee","Memo","Outflow","Inflow"
|
8
|
+
# "23/12/2019","coaxial","","1000000.00",""
|
9
|
+
# "30/12/2019","Santa","","50000.00",""
|
10
|
+
# "02/02/2020","Someone Else","","45.00",""
|
11
|
+
# ROWS
|
12
|
+
# The file name for the processor should be the institution name in
|
13
|
+
# camel case. It's ok to skip "Bank" or "Credit Union" when naming the file
|
14
|
+
# if it's redundant. For instance, this parser is for "Example Bank" but it's
|
15
|
+
# named "example.rb", its corresponding spec is
|
16
|
+
# "spec/example_processor_spec.rb" and its fixture would be
|
17
|
+
# "spec/fixtures/example.csv"
|
18
|
+
class Example < Processor::Base
|
19
|
+
# @option options [String] :file Path to the CSV file to process
|
20
|
+
def initialize(options)
|
21
|
+
# Custom converters can be added so that the CSV data is parsed when
|
22
|
+
# loading the original file
|
23
|
+
register_custom_converters
|
24
|
+
|
25
|
+
# These are the options for the CSV module (see
|
26
|
+
# https://ruby-doc.org/stdlib-2.6/libdoc/csv/rdoc/CSV.html#method-c-new)
|
27
|
+
# They should match the format for the CSV file that the financial
|
28
|
+
# institution generates.
|
29
|
+
@loader_options = {
|
30
|
+
col_sep: ';',
|
31
|
+
# Use your converters, if any
|
32
|
+
converters: %i[transaction_date my_converter],
|
33
|
+
headers: true
|
34
|
+
}
|
35
|
+
|
36
|
+
# This is the financial institution's full name as it calls itself. This
|
37
|
+
# usually matches the institution's letterhead and/or commercial name.
|
38
|
+
# It can happen that the same institution needs different parsers because
|
39
|
+
# its credit card CSV files are in one format, and its chequing accounts
|
40
|
+
# in another. In that case, more details can be added in parens.
|
41
|
+
# For instance:
|
42
|
+
# 'Example Bank (credit cards)' and 'Example Bank (chequing)'
|
43
|
+
@institution_name = 'Example Bank'
|
44
|
+
|
45
|
+
# This is mandatory.
|
46
|
+
super(options)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def register_custom_converters
|
52
|
+
CSV::Converters[:transaction_date] = lambda { |s|
|
53
|
+
# Only match strings that have two digits, a dot, two digits, a dot,
|
54
|
+
# two digits, i.e. the dates in this institution's CSV files.
|
55
|
+
date_regex = /^\d{2}\.\d{2}\.\d{2}$/
|
56
|
+
|
57
|
+
if !s.nil? && s.match(date_regex)
|
58
|
+
parsed_date = Date.strptime(s, '%d.%m.%y')
|
59
|
+
logger.debug "Converted `#{s.inspect}' into date "\
|
60
|
+
"`#{parsed_date}'"
|
61
|
+
return parsed_date
|
62
|
+
end
|
63
|
+
|
64
|
+
s
|
65
|
+
}
|
66
|
+
|
67
|
+
CSV::Converters[:my_converter] = lambda { |s|
|
68
|
+
# A contrived example, just to illustrate multiple converters
|
69
|
+
if s.respond_to?(:downcase)
|
70
|
+
converted_s = s.downcase
|
71
|
+
logger.debug "Converted `#{s.inspect}' into downcased string "\
|
72
|
+
"`#{converted_s}'"
|
73
|
+
return converted_s
|
74
|
+
end
|
75
|
+
|
76
|
+
s
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
protected
|
81
|
+
|
82
|
+
# Converts the institution's CSV rows into YNAB4 rows.
|
83
|
+
# The YNAB4 columns are:
|
84
|
+
# "Date', "Payee", "Memo", "Outflow", "Inflow"
|
85
|
+
# which match Example Bank's "transaction_date" (after parsing),
|
86
|
+
# "beneficiary", nothing, "debit", and "credit" respectively.
|
87
|
+
# Note that Example Bank doesn't include any relevant column for YNAB4's
|
88
|
+
# "Memo" column so it's skipped and gets '' as its value.
|
89
|
+
def transformers(row)
|
90
|
+
# Convert the original transaction_date to DD/MM/YYYY as YNAB4 expects
|
91
|
+
# it.
|
92
|
+
unless row[headers[:transaction_date]].nil?
|
93
|
+
transaction_date = row[headers[:transaction_date]].strftime('%d/%m/%Y')
|
94
|
+
end
|
95
|
+
payee = row[headers[:payee]]
|
96
|
+
debit = row[headers[:debit]]
|
97
|
+
credit = row[headers[:credit]]
|
98
|
+
|
99
|
+
# CSV files can have funny data in them, including invalid or empty rows.
|
100
|
+
# These rows can be skipped from the converted YNAB4 file by calling
|
101
|
+
# skip_row when detected. In this particular case, if there is no
|
102
|
+
# transaction date, it means the row is empty or invalid and we discard
|
103
|
+
# it.
|
104
|
+
skip_row(row) if transaction_date.nil?
|
105
|
+
converted_row = [transaction_date, payee, nil, debit, credit]
|
106
|
+
logger.debug "Converted row: #{converted_row}"
|
107
|
+
converted_row
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
# Institutions love translating the column names, apparently. Rather than
|
113
|
+
# hardcoding the column name as a string, use the headers array at the
|
114
|
+
# right index.
|
115
|
+
# These lookups aren't particularly expensive but they're done on each row
|
116
|
+
# so why not memoize them with ||=
|
117
|
+
def extract_header_names(row)
|
118
|
+
headers[:transaction_date] ||= row.headers[0]
|
119
|
+
headers[:payee] ||= row.headers[2]
|
120
|
+
headers[:debit] ||= row.headers[3]
|
121
|
+
headers[:credit] ||= row.headers[4]
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n'
|
4
|
+
|
5
|
+
module Processor
|
6
|
+
# Processes CSV files from Revolut
|
7
|
+
class Revolut < Processor::Base
|
8
|
+
# @option options [String] :file Path to the CSV file to process
|
9
|
+
def initialize(options)
|
10
|
+
register_custom_converters
|
11
|
+
@loader_options = {
|
12
|
+
col_sep: ';',
|
13
|
+
converters: %i[amounts transaction_dates],
|
14
|
+
quote_char: nil,
|
15
|
+
encoding: Encoding::UTF_8,
|
16
|
+
headers: true
|
17
|
+
}
|
18
|
+
@institution_name = 'Revolut'
|
19
|
+
|
20
|
+
super(options)
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
def transformers(row)
|
26
|
+
date = extract_transaction_date(row).strftime('%d/%m/%Y')
|
27
|
+
payee = row[headers[:payee]]
|
28
|
+
unless row[headers[:debit]].nil?
|
29
|
+
debit = format('%<amount>.2f', amount: row[headers[:debit]])
|
30
|
+
end
|
31
|
+
unless row[headers[:credit]].nil?
|
32
|
+
credit = format('%<amount>.2f', amount: row[headers[:credit]])
|
33
|
+
end
|
34
|
+
|
35
|
+
ynab_row = [
|
36
|
+
date,
|
37
|
+
payee,
|
38
|
+
nil,
|
39
|
+
debit,
|
40
|
+
credit
|
41
|
+
]
|
42
|
+
|
43
|
+
logger.debug "Converted row: #{ynab_row}"
|
44
|
+
ynab_row
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def extract_header_names(row)
|
50
|
+
@headers[:transaction_date] ||= row.headers[0]
|
51
|
+
@headers[:payee] ||= row.headers[1]
|
52
|
+
@headers[:debit] ||= row.headers[2]
|
53
|
+
@headers[:credit] ||= row.headers[3]
|
54
|
+
end
|
55
|
+
|
56
|
+
def register_custom_converters
|
57
|
+
CSV::Converters[:amounts] = lambda { |s|
|
58
|
+
# Yes, amount come with a non breaking trailing space... Which is
|
59
|
+
# matched with \p{Zs} (c.f.
|
60
|
+
# https://ruby-doc.org/core-2.6/Regexp.html#class-Regexp-label-Character+Properties)
|
61
|
+
# Also, thousands separators can be non breaking spaces.
|
62
|
+
amount_regex = /^[\d'\.,\p{Zs}]+[\.,]\d{2}\p{Zs}$/
|
63
|
+
# narrow_nbsp = "\0xE2\0x80\0xAF"
|
64
|
+
narrow_nbsp = "\u{202F}"
|
65
|
+
readability_separators = "',. #{narrow_nbsp}"
|
66
|
+
|
67
|
+
if !s.nil? && s.match(amount_regex)
|
68
|
+
# This is a bit hacky because we don't have the luxury of Rails' i18n
|
69
|
+
# helpers. If we have an amount, strip all the separators in it, turn
|
70
|
+
# it to a float, and divide by 100 to get the right amount back
|
71
|
+
amount = s.delete(readability_separators).to_f / 100
|
72
|
+
logger.debug "Converted `#{s}' into amount `#{amount}'"
|
73
|
+
return amount
|
74
|
+
end
|
75
|
+
|
76
|
+
logger.debug "Not an amount, not parsing `#{s.inspect}'"
|
77
|
+
s
|
78
|
+
}
|
79
|
+
|
80
|
+
# rubocop:disable Style/AsciiComments
|
81
|
+
CSV::Converters[:transaction_dates] = lambda { |s|
|
82
|
+
begin
|
83
|
+
# Date.parse('6 decembre') is fine, but Date.parse('6 décembre') is
|
84
|
+
# an invalid date so we must remove diacritics before trying to parse
|
85
|
+
I18n.available_locales = [:en]
|
86
|
+
transliterated_s = I18n.transliterate s
|
87
|
+
logger.debug "Converted `#{s.inspect}' into date "\
|
88
|
+
"`#{Date.parse(transliterated_s)}'"
|
89
|
+
Date.parse(transliterated_s)
|
90
|
+
rescue StandardError
|
91
|
+
logger.debug "Not a date, not parsing #{s.inspect}"
|
92
|
+
s
|
93
|
+
end
|
94
|
+
}
|
95
|
+
# rubocop:enable Style/AsciiComments
|
96
|
+
end
|
97
|
+
|
98
|
+
def missing_transaction_date?(row)
|
99
|
+
# If It's missing a transaction date, it's most likely invalid
|
100
|
+
row[headers[:transaction_date]].nil?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processor
|
4
|
+
# Processes CSV files from UBS Personal Banking Switzerland (French)
|
5
|
+
class UbsChequing < Processor::Base
|
6
|
+
# @option options [String] :file Path to the CSV file to process
|
7
|
+
def initialize(options)
|
8
|
+
register_custom_converters
|
9
|
+
@loader_options = {
|
10
|
+
col_sep: ';',
|
11
|
+
converters: %i[amounts transaction_dates],
|
12
|
+
quote_char: nil,
|
13
|
+
encoding: Encoding::UTF_8,
|
14
|
+
headers: true
|
15
|
+
}
|
16
|
+
@institution_name = 'UBS (Chequing)'
|
17
|
+
|
18
|
+
super(options)
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
def transformers(row)
|
24
|
+
date = extract_transaction_date(row).strftime('%d/%m/%Y')
|
25
|
+
payee = transaction_payee(row)
|
26
|
+
unless row[headers[:debit]].nil?
|
27
|
+
debit = format('%<amount>.2f', amount: row[headers[:debit]])
|
28
|
+
end
|
29
|
+
unless row[headers[:credit]].nil?
|
30
|
+
credit = format('%<amount>.2f', amount: row[headers[:credit]])
|
31
|
+
end
|
32
|
+
|
33
|
+
converted_row = [
|
34
|
+
date,
|
35
|
+
payee,
|
36
|
+
nil,
|
37
|
+
debit,
|
38
|
+
credit
|
39
|
+
]
|
40
|
+
|
41
|
+
logger.debug "Converted row: #{converted_row}"
|
42
|
+
converted_row
|
43
|
+
end
|
44
|
+
|
45
|
+
def extract_transaction_date(row)
|
46
|
+
skip_row(row) if row[headers[:transaction_date]].nil?
|
47
|
+
row[headers[:transaction_date]]
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def extract_header_names(row)
|
53
|
+
headers[:transaction_date] ||= row.headers[9]
|
54
|
+
headers[:payee_line_1] ||= row.headers[12]
|
55
|
+
headers[:payee_line_2] ||= row.headers[13]
|
56
|
+
headers[:payee_line_3] ||= row.headers[14]
|
57
|
+
headers[:debit] ||= row.headers[18]
|
58
|
+
headers[:credit] ||= row.headers[19]
|
59
|
+
end
|
60
|
+
|
61
|
+
def transaction_payee(row)
|
62
|
+
# Transaction description is spread over 3 columns
|
63
|
+
[
|
64
|
+
row[headers[:payee_line_1]],
|
65
|
+
row[headers[:payee_line_2]],
|
66
|
+
row[headers[:payee_line_3]]
|
67
|
+
].join(' ')
|
68
|
+
end
|
69
|
+
|
70
|
+
def register_custom_converters
|
71
|
+
CSV::Converters[:amounts] = lambda { |s|
|
72
|
+
# Regex checks if string has only digits, apostrophes, and ends with a
|
73
|
+
# dot and two digits
|
74
|
+
amount_regex = /^[\d'?]+\.\d{2}$/
|
75
|
+
|
76
|
+
if !s.nil? && s.match(amount_regex)
|
77
|
+
amount = s.delete("'") .to_f
|
78
|
+
logger.debug "Converted `#{s}' into amount `#{amount}'"
|
79
|
+
return amount
|
80
|
+
end
|
81
|
+
|
82
|
+
logger.debug "Not an amount, not parsing `#{s.inspect}'"
|
83
|
+
s
|
84
|
+
}
|
85
|
+
|
86
|
+
CSV::Converters[:transaction_dates] = lambda { |s|
|
87
|
+
date_regex = /^\d{2}\.\d{2}\.\d{4}$/
|
88
|
+
|
89
|
+
if !s.nil? && s.match(date_regex)
|
90
|
+
parsed_date = Date.parse(s)
|
91
|
+
logger.debug "Converted `#{s.inspect}' into date "\
|
92
|
+
"`#{parsed_date}'"
|
93
|
+
parsed_date
|
94
|
+
else
|
95
|
+
logger.debug "Not a date, not parsing #{s.inspect}"
|
96
|
+
s
|
97
|
+
end
|
98
|
+
}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Processor
|
4
|
+
# Processes CSV files from UBS Credit Cards Switzerland (French)
|
5
|
+
class UbsCredit < Processor::Base
|
6
|
+
# @option options [String] :file Path to the CSV file to process
|
7
|
+
def initialize(options)
|
8
|
+
register_custom_converters
|
9
|
+
@loader_options = {
|
10
|
+
col_sep: ';',
|
11
|
+
converters: %i[amounts transaction_dates],
|
12
|
+
quote_char: nil,
|
13
|
+
encoding: "#{Encoding::ISO_8859_1}:#{Encoding::UTF_8}",
|
14
|
+
headers: true,
|
15
|
+
# CSV FTW, the first line in these files is not the headers but the
|
16
|
+
# separator specification
|
17
|
+
skip_lines: 'sep=;'
|
18
|
+
}
|
19
|
+
@institution_name = 'UBS (Credit cards)'
|
20
|
+
|
21
|
+
super(options)
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def transformers(row)
|
27
|
+
unless row[headers[:transaction_date]].nil?
|
28
|
+
date = row[headers[:transaction_date]].strftime('%d/%m/%Y')
|
29
|
+
end
|
30
|
+
payee = row[headers[:payee]]
|
31
|
+
unless row[headers[:debit]].nil?
|
32
|
+
debit = format('%<amount>.2f', amount: row[headers[:debit]])
|
33
|
+
end
|
34
|
+
unless row[headers[:credit]].nil?
|
35
|
+
credit = format('%<amount>.2f', amount: row[headers[:credit]])
|
36
|
+
end
|
37
|
+
|
38
|
+
converted_row = [date, payee, nil, debit, credit]
|
39
|
+
logger.debug "Converted row: #{converted_row}"
|
40
|
+
converted_row
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def extract_header_names(row)
|
46
|
+
headers[:transaction_date] ||= row.headers[3]
|
47
|
+
headers[:payee] ||= row.headers[4]
|
48
|
+
headers[:debit] ||= row.headers[10]
|
49
|
+
headers[:credit] ||= row.headers[11]
|
50
|
+
end
|
51
|
+
|
52
|
+
def register_custom_converters
|
53
|
+
CSV::Converters[:amounts] = lambda { |s|
|
54
|
+
# Regex checks if string has only digits, apostrophes, and ends with a
|
55
|
+
# dot and two digits
|
56
|
+
amount_regex = /^[\d'?]+(\.\d{2})$/
|
57
|
+
|
58
|
+
if !s.nil? && s.match(amount_regex)
|
59
|
+
amount = s.delete("'") .to_f
|
60
|
+
logger.debug "Converted `#{s}' into amount `#{amount}'"
|
61
|
+
return amount
|
62
|
+
end
|
63
|
+
|
64
|
+
logger.debug "Not an amount, not parsing `#{s.inspect}'"
|
65
|
+
s
|
66
|
+
}
|
67
|
+
|
68
|
+
CSV::Converters[:transaction_dates] = lambda { |s|
|
69
|
+
date_regex = /^\d{2}\.\d{2}\.\d{4}$/
|
70
|
+
|
71
|
+
if !s.nil? && s.match(date_regex)
|
72
|
+
parsed_date = Date.parse(s)
|
73
|
+
logger.debug "Converted `#{s.inspect}' into date "\
|
74
|
+
"`#{parsed_date}'"
|
75
|
+
parsed_date
|
76
|
+
else
|
77
|
+
logger.debug "Not a date, not parsing #{s.inspect}"
|
78
|
+
s
|
79
|
+
end
|
80
|
+
}
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|