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.
- checksums.yaml +4 -4
- data/.github/workflows/publish.yml +36 -0
- data/.github/workflows/test.yml +31 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +10 -2
- data/Gemfile.lock +39 -14
- data/Guardfile +1 -29
- data/README.md +82 -7
- data/lib/ynab_convert/api_clients/api_client.rb +24 -0
- data/lib/ynab_convert/api_clients/currency_api.rb +66 -0
- data/lib/ynab_convert/documents/statements/example_statement.rb +16 -0
- data/lib/ynab_convert/documents/statements/n26_statement.rb +24 -0
- data/lib/ynab_convert/documents/statements/statement.rb +39 -0
- data/lib/ynab_convert/documents/statements/ubs_chequing_statement.rb +20 -0
- data/lib/ynab_convert/documents/statements/ubs_credit_statement.rb +19 -0
- data/lib/ynab_convert/documents/statements/wise_statement.rb +17 -0
- data/lib/ynab_convert/documents/ynab4_files/ynab4_file.rb +58 -0
- data/lib/ynab_convert/documents.rb +17 -0
- data/lib/ynab_convert/logger.rb +1 -1
- data/lib/ynab_convert/processors/example_processor.rb +24 -0
- data/lib/ynab_convert/processors/n26_processor.rb +26 -0
- data/lib/ynab_convert/processors/processor.rb +75 -0
- data/lib/ynab_convert/processors/ubs_chequing_processor.rb +21 -0
- data/lib/ynab_convert/processors/ubs_credit_processor.rb +17 -0
- data/lib/ynab_convert/processors/wise_processor.rb +19 -0
- data/lib/ynab_convert/processors.rb +2 -2
- data/lib/ynab_convert/transformers/cleaners/cleaner.rb +17 -0
- data/lib/ynab_convert/transformers/cleaners/n26_cleaner.rb +13 -0
- data/lib/ynab_convert/transformers/cleaners/ubs_chequing_cleaner.rb +98 -0
- data/lib/ynab_convert/transformers/cleaners/ubs_credit_cleaner.rb +45 -0
- data/lib/ynab_convert/transformers/cleaners/wise_cleaner.rb +39 -0
- data/lib/ynab_convert/transformers/enhancers/enhancer.rb +20 -0
- data/lib/ynab_convert/transformers/enhancers/n26_enhancer.rb +74 -0
- data/lib/ynab_convert/transformers/enhancers/wise_enhancer.rb +87 -0
- data/lib/ynab_convert/transformers/formatters/example_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/formatter.rb +91 -0
- data/lib/ynab_convert/transformers/formatters/n26_formatter.rb +19 -0
- data/lib/ynab_convert/transformers/formatters/ubs_chequing_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/ubs_credit_formatter.rb +12 -0
- data/lib/ynab_convert/transformers/formatters/wise_formatter.rb +35 -0
- data/lib/ynab_convert/transformers.rb +18 -0
- data/lib/ynab_convert/validators/ynab4_row_validator.rb +83 -0
- data/lib/ynab_convert/validators.rb +9 -0
- data/lib/ynab_convert/version.rb +1 -1
- data/lib/ynab_convert.rb +22 -3
- data/ynab_convert.gemspec +4 -0
- metadata +94 -10
- data/.travis.yml +0 -20
- data/lib/ynab_convert/processor/base.rb +0 -226
- data/lib/ynab_convert/processor/example.rb +0 -124
- data/lib/ynab_convert/processor/n26.rb +0 -70
- data/lib/ynab_convert/processor/revolut.rb +0 -103
- data/lib/ynab_convert/processor/ubs_chequing.rb +0 -137
- 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
|