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.
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