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