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