ynab_convert 1.0.8 → 2.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/publish.yml +36 -0
  3. data/.github/workflows/test.yml +31 -0
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -2
  6. data/Gemfile.lock +39 -14
  7. data/Guardfile +1 -29
  8. data/README.md +82 -7
  9. data/lib/ynab_convert/api_clients/api_client.rb +24 -0
  10. data/lib/ynab_convert/api_clients/currency_api.rb +66 -0
  11. data/lib/ynab_convert/documents/statements/example_statement.rb +16 -0
  12. data/lib/ynab_convert/documents/statements/n26_statement.rb +24 -0
  13. data/lib/ynab_convert/documents/statements/statement.rb +39 -0
  14. data/lib/ynab_convert/documents/statements/ubs_chequing_statement.rb +20 -0
  15. data/lib/ynab_convert/documents/statements/ubs_credit_statement.rb +19 -0
  16. data/lib/ynab_convert/documents/statements/wise_statement.rb +17 -0
  17. data/lib/ynab_convert/documents/ynab4_files/ynab4_file.rb +58 -0
  18. data/lib/ynab_convert/documents.rb +17 -0
  19. data/lib/ynab_convert/logger.rb +1 -1
  20. data/lib/ynab_convert/processors/example_processor.rb +24 -0
  21. data/lib/ynab_convert/processors/n26_processor.rb +26 -0
  22. data/lib/ynab_convert/processors/processor.rb +75 -0
  23. data/lib/ynab_convert/processors/ubs_chequing_processor.rb +21 -0
  24. data/lib/ynab_convert/processors/ubs_credit_processor.rb +17 -0
  25. data/lib/ynab_convert/processors/wise_processor.rb +19 -0
  26. data/lib/ynab_convert/processors.rb +2 -2
  27. data/lib/ynab_convert/transformers/cleaners/cleaner.rb +17 -0
  28. data/lib/ynab_convert/transformers/cleaners/n26_cleaner.rb +13 -0
  29. data/lib/ynab_convert/transformers/cleaners/ubs_chequing_cleaner.rb +98 -0
  30. data/lib/ynab_convert/transformers/cleaners/ubs_credit_cleaner.rb +45 -0
  31. data/lib/ynab_convert/transformers/cleaners/wise_cleaner.rb +39 -0
  32. data/lib/ynab_convert/transformers/enhancers/enhancer.rb +20 -0
  33. data/lib/ynab_convert/transformers/enhancers/n26_enhancer.rb +74 -0
  34. data/lib/ynab_convert/transformers/enhancers/wise_enhancer.rb +87 -0
  35. data/lib/ynab_convert/transformers/formatters/example_formatter.rb +12 -0
  36. data/lib/ynab_convert/transformers/formatters/formatter.rb +91 -0
  37. data/lib/ynab_convert/transformers/formatters/n26_formatter.rb +19 -0
  38. data/lib/ynab_convert/transformers/formatters/ubs_chequing_formatter.rb +12 -0
  39. data/lib/ynab_convert/transformers/formatters/ubs_credit_formatter.rb +12 -0
  40. data/lib/ynab_convert/transformers/formatters/wise_formatter.rb +35 -0
  41. data/lib/ynab_convert/transformers.rb +18 -0
  42. data/lib/ynab_convert/validators/ynab4_row_validator.rb +83 -0
  43. data/lib/ynab_convert/validators.rb +9 -0
  44. data/lib/ynab_convert/version.rb +1 -1
  45. data/lib/ynab_convert.rb +22 -3
  46. data/ynab_convert.gemspec +4 -0
  47. metadata +94 -10
  48. data/.travis.yml +0 -20
  49. data/lib/ynab_convert/processor/base.rb +0 -226
  50. data/lib/ynab_convert/processor/example.rb +0 -124
  51. data/lib/ynab_convert/processor/n26.rb +0 -70
  52. data/lib/ynab_convert/processor/revolut.rb +0 -103
  53. data/lib/ynab_convert/processor/ubs_chequing.rb +0 -137
  54. data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -1,226 +0,0 @@
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
-
10
- # rubocop:disable Metrics/ClassLength
11
- class Base
12
- include YnabLogger
13
- include CoreExtensions::String::Inflections
14
-
15
- attr_reader :loader_options
16
-
17
- # @option options [String] :file Path to the CSV file to process
18
- # @option options [Symbol] :format YNAB4 format to use, one of :flows or
19
- # :amounts. :flows is useful for CSVs with separate debit and credit
20
- # columns, :amounts is for CSVs with only one amount columns and +/-
21
- # numbers. See
22
- # https://docs.youneedabudget.com/article/921-formatting-csv-file
23
- def initialize(options)
24
- default_options = { file: '', format: :flows }
25
- opts = default_options.merge(options)
26
-
27
- logger.debug "Initializing processor with options: `#{opts.to_h}'"
28
- raise ::Errno::ENOENT unless File.exist? opts[:file]
29
-
30
- @file = opts[:file]
31
- @headers = { transaction_date: nil, payee: nil }
32
- @format = opts[:format]
33
-
34
- if @format == :amounts
35
- amounts_columns = { amount: nil }
36
- @headers.merge!(amounts_columns)
37
- else
38
- flows_columns = { inflow: nil, outflow: nil }
39
- @headers.merge!(flows_columns)
40
- end
41
- end
42
-
43
- def to_ynab!
44
- begin
45
- convert!
46
- rename_file
47
- rescue YnabConvert::Error
48
- invalid_csv_file
49
- end
50
- ensure
51
- logger.debug "Deleting temp file `#{temp_filename}'"
52
- delete_temp_csv
53
- end
54
-
55
- protected
56
-
57
- attr_accessor :statement_from, :statement_to, :headers
58
-
59
- def amount_invalid?(row)
60
- amount_index = 3
61
-
62
- # If there is no amount,
63
- # then the row is invalid.
64
- row[amount_index].nil? || row[amount_index].empty?
65
- end
66
-
67
- def inflow_outflow_invalid?(row)
68
- inflow_index = 3
69
- outflow_index = 4
70
-
71
- # If there is neither inflow and outflow values,
72
- # or both the inflow and outflow amounts are 0,
73
- # then the row is invalid.
74
- (
75
- row[inflow_index].nil? ||
76
- row[inflow_index].empty? ||
77
- row[inflow_index] == '0.00'
78
- ) && (
79
- row[outflow_index].nil? ||
80
- row[outflow_index].empty? ||
81
- row[outflow_index] == '0.00'
82
- )
83
- end
84
-
85
- def amounts_missing?(row)
86
- logger.debug "Checking for missing amount in `#{row}`"
87
- if @format == :amounts
88
- logger.debug 'Using `:amounts`'
89
- amount_invalid?(row)
90
- else
91
- logger.debug 'Using `:flows`'
92
- inflow_outflow_invalid?(row)
93
- end
94
- end
95
-
96
- def skip_row(row)
97
- logger.debug "Found empty row, skipping it: #{row.to_h}"
98
- throw :skip_row
99
- end
100
-
101
- def delete_temp_csv
102
- FileUtils.remove_file temp_filename, force: true
103
- end
104
-
105
- def transaction_date_missing?(ynab_row)
106
- ynab_row[0].nil? || [0].empty?
107
- end
108
-
109
- def extract_transaction_date(ynab_row)
110
- transaction_date_index = 0
111
- ynab_row[transaction_date_index]
112
- end
113
-
114
- def record_statement_interval_dates(ynab_row)
115
- transaction_date_index = 0
116
- date = Date.parse(ynab_row[transaction_date_index])
117
-
118
- if date_is_further_away?(date)
119
- logger.debug "Replacing statement_from `#{statement_from.inspect}' "\
120
- "with `#{date}'"
121
- self.statement_from = date
122
- end
123
- # rubocop:disable Style/GuardClause
124
- if date_is_more_recent?(date)
125
- logger.debug "Replacing statement_to `#{statement_to.inspect}' with "\
126
- "`#{date}'"
127
- self.statement_to = date
128
- end
129
- # rubocop:enable Style/GuardClause
130
- end
131
-
132
- def date_is_more_recent?(date)
133
- statement_to.nil? || statement_to < date
134
- end
135
-
136
- def date_is_further_away?(date)
137
- statement_from.nil? || statement_from > date
138
- end
139
-
140
- def convert!
141
- logger.debug "Will write to `#{temp_filename}'"
142
-
143
- logger.debug(loader_options)
144
- CSV.open(temp_filename, 'wb', **output_options) do |converted|
145
- CSV.foreach(@file, 'rb', **loader_options) do |row|
146
- logger.debug "Parsing row: `#{row.to_h}'"
147
- # Some rows don't contain valid or useful data
148
- catch :skip_row do
149
- extract_header_names(row)
150
- ynab_row = transformers(row)
151
- if amounts_missing?(ynab_row) ||
152
- transaction_date_missing?(ynab_row)
153
- logger.debug 'Empty row, skipping it'
154
- skip_row(row)
155
- end
156
- converted << ynab_row
157
- record_statement_interval_dates(ynab_row)
158
- end
159
-
160
- logger.debug 'Done converting'
161
- end
162
- end
163
- end
164
-
165
- def rename_file
166
- File.rename(temp_filename, output_filename)
167
- logger.debug "Renamed temp file `#{temp_filename}' to "\
168
- "`#{output_filename}'"
169
- end
170
-
171
- def invalid_csv_file
172
- raise YnabConvert::Error, "Unable to parse file `#{@file}'. Is it a "\
173
- "valid CSV file from #{@institution_name}?"
174
- end
175
-
176
- def file_uid
177
- @file_uid ||= rand(36**8).to_s(36)
178
- end
179
-
180
- def temp_filename
181
- "#{File.basename(@file, '.csv')}_#{@institution_name.snake_case}_"\
182
- "#{file_uid}_ynab4.csv"
183
- end
184
-
185
- def output_filename
186
- # If the file contained no parsable CSV data, from and to dates will be
187
- # nil.
188
- # This is to avoid a NoMethodError on NilClass.
189
- raise YnabConvert::Error if statement_from.nil? || statement_to.nil?
190
-
191
- from = statement_from.strftime('%Y%m%d')
192
- to = statement_to.strftime('%Y%m%d')
193
-
194
- "#{File.basename(@file, '.csv')}_#{@institution_name.snake_case}_"\
195
- "#{from}-#{to}_ynab4.csv"
196
- end
197
-
198
- def ynab_headers
199
- common_headers = %w[Date Payee Memo]
200
-
201
- if @format == :amounts
202
- amounts_headers = %w[Amount]
203
- common_headers.concat(amounts_headers)
204
- else
205
- flows_headers = %w[Outflow Inflow]
206
- common_headers.concat(flows_headers)
207
- end
208
-
209
- common_headers
210
- end
211
-
212
- def output_options
213
- {
214
- converters: %i[numeric date],
215
- force_quotes: true,
216
- write_headers: true,
217
- headers: ynab_headers
218
- }
219
- end
220
-
221
- def transformers
222
- raise NotImplementedError, :transformers
223
- end
224
- end
225
- # rubocop:enable Metrics/ClassLength
226
- end
@@ -1,124 +0,0 @@
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/statement.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
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Processor
4
- # Processes CSV files from N26
5
- class N26 < Processor::Base
6
- # @option options [String] :file Path to the CSV file to process
7
- def initialize(options)
8
- # Custom converters can be added so that the CSV data is parsed when
9
- # loading the original file
10
- register_custom_converters
11
-
12
- # These are the options for the CSV module (see
13
- # https://ruby-doc.org/stdlib-2.6/libdoc/csv/rdoc/CSV.html#method-c-new)
14
- # They should match the format for the CSV file that the financial
15
- # institution generates.
16
- @loader_options = {
17
- col_sep: ',',
18
- quote_char: '"',
19
- # Use your converters, if any
20
- # converters: %i[],
21
- headers: true,
22
- encoding: 'bom|utf-8'
23
- }
24
-
25
- # This is the financial institution's full name as it calls itself. This
26
- # usually matches the institution's letterhead and/or commercial name.
27
- # It can happen that the same institution needs different parsers because
28
- # its credit card CSV files are in one format, and its chequing accounts
29
- # in another. In that case, more details can be added in parens.
30
- # For instance:
31
- # 'Example Bank (credit cards)' and 'Example Bank (chequing)'
32
- @institution_name = 'N26 Bank'
33
- # N26's CSV only has one columns for all transactions instead of separate
34
- # debit and credit columns
35
- additional_processor_options = { format: :amounts }
36
-
37
- # This is mandatory.
38
- super(options.merge(additional_processor_options))
39
- end
40
-
41
- private
42
-
43
- def register_custom_converters; end
44
-
45
- protected
46
-
47
- def transformers(row)
48
- transaction_date = row[headers[:transaction_date]]
49
- payee = row[headers[:payee]]
50
- amount = row[headers[:amount]]
51
-
52
- converted_row = [transaction_date, payee, nil, amount]
53
- logger.debug "Converted row: #{converted_row}"
54
- converted_row
55
- end
56
-
57
- private
58
-
59
- # Institutions love translating the column names, apparently. Rather than
60
- # hardcoding the column name as a string, use the headers array at the
61
- # right index.
62
- # These lookups aren't particularly expensive but they're done on each row
63
- # so why not memoize them with ||=
64
- def extract_header_names(row)
65
- headers[:transaction_date] ||= row.headers[0]
66
- headers[:payee] ||= row.headers[1]
67
- headers[:amount] ||= row.headers[5]
68
- end
69
- end
70
- end
@@ -1,103 +0,0 @@
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
@@ -1,137 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Processor
4
- # Processes CSV files from UBS Personal Banking Switzerland
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
- raw_payee_line = [
63
- row[headers[:payee_line_2]],
64
- row[headers[:payee_line_3]]
65
- ]
66
-
67
- # Transaction description is spread over 3 columns.
68
- # There are two types of entries:
69
- # 1. only the first column contains data
70
- # 2. all three columns contain data, most of it junk
71
- #
72
- # Cleaning them up means dropping the first column if there is anything
73
- # in the other columns;
74
- # removing the CARD 00000000-0 0000 at the beginning of debit card
75
- # payment entries;
76
- # removing the rest of the junk appended after the worthwhile data (see
77
- # below for details on that)
78
- if row[headers[:payee_line_2]].nil?
79
- # Make it an Array, for consistency
80
- raw_payee_line = [row[headers[:payee_line_1]]]
81
- end
82
-
83
- concat_payee_line = raw_payee_line.join(' ')
84
-
85
- # Moreover, UBS thought wise to append a bunch of junk information after
86
- # the transaction details within the third description field. *Most* of
87
- # this junk starts after the meaningful data and starts with ", OF",
88
- # ", ON", ", ESR", ", QRR", two digits then five groups of five digits
89
- # then ", TN" so we discard it; YNAB4 being unable to automatically
90
- # categorize new transactions at the same store/payee because the payee
91
- # always looks different (thanks to the variable nature of the appended
92
- # junk).
93
- # See `spec/fixtures/ubs_chequing/statement.csv` L2 and L18--22
94
-
95
- # rubocop:disable Metrics/LineLength
96
- junk_desc_regex = /,? (O[FN]|ESR|QRR|\d{2} \d{5} \d{5} \d{5} \d{5} \d{5}, TN).*/
97
- # rubocop:enable Metrics/LineLength
98
-
99
- # Of course, it wouldn't be complete with more junk information at the
100
- # beginning of *some* lines (debit card payments)
101
- debit_card_junk_regex = /CARD \d{8}\-\d \d{4} /
102
-
103
- concat_payee_line.sub(junk_desc_regex, '').sub(debit_card_junk_regex, '')
104
- end
105
-
106
- def register_custom_converters
107
- CSV::Converters[:amounts] = lambda { |s|
108
- # Regex checks if string has only digits, apostrophes, and ends with a
109
- # dot and two digits
110
- amount_regex = /^[\d'?]+\.\d{2}$/
111
-
112
- if !s.nil? && s.match(amount_regex)
113
- amount = s.delete("'") .to_f
114
- logger.debug "Converted `#{s}' into amount `#{amount}'"
115
- return amount
116
- end
117
-
118
- logger.debug "Not an amount, not parsing `#{s.inspect}'"
119
- s
120
- }
121
-
122
- CSV::Converters[:transaction_dates] = lambda { |s|
123
- date_regex = /^\d{2}\.\d{2}\.\d{4}$/
124
-
125
- if !s.nil? && s.match(date_regex)
126
- parsed_date = Date.parse(s)
127
- logger.debug "Converted `#{s.inspect}' into date "\
128
- "`#{parsed_date}'"
129
- parsed_date
130
- else
131
- logger.debug "Not a date, not parsing #{s.inspect}"
132
- s
133
- end
134
- }
135
- end
136
- end
137
- end