ynab_convert 0.1.0.pre
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 +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
|