ynab_convert 0.1.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base processor must be loaded first as all others inherit from it
4
+ require 'ynab_convert/processor/base'
5
+
6
+ # Load all known processors
7
+ Dir[File.join(__dir__, 'processor', '*.rb')].each { |file| require file }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YnabConvert
4
+ VERSION = '0.1.0.pre'
5
+ end