ynab_convert 1.0.8 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +10 -2
  4. data/Gemfile.lock +37 -12
  5. data/Guardfile +1 -29
  6. data/README.md +76 -5
  7. data/lib/ynab_convert/api_clients/api_client.rb +24 -0
  8. data/lib/ynab_convert/api_clients/currency_api.rb +66 -0
  9. data/lib/ynab_convert/documents/statements/example_statement.rb +16 -0
  10. data/lib/ynab_convert/documents/statements/n26_statement.rb +24 -0
  11. data/lib/ynab_convert/documents/statements/statement.rb +39 -0
  12. data/lib/ynab_convert/documents/statements/ubs_chequing_statement.rb +20 -0
  13. data/lib/ynab_convert/documents/statements/ubs_credit_statement.rb +19 -0
  14. data/lib/ynab_convert/documents/statements/wise_statement.rb +17 -0
  15. data/lib/ynab_convert/documents/ynab4_files/ynab4_file.rb +58 -0
  16. data/lib/ynab_convert/documents.rb +17 -0
  17. data/lib/ynab_convert/logger.rb +1 -1
  18. data/lib/ynab_convert/processors/example_processor.rb +24 -0
  19. data/lib/ynab_convert/processors/n26_processor.rb +26 -0
  20. data/lib/ynab_convert/processors/processor.rb +75 -0
  21. data/lib/ynab_convert/processors/ubs_chequing_processor.rb +21 -0
  22. data/lib/ynab_convert/processors/ubs_credit_processor.rb +17 -0
  23. data/lib/ynab_convert/processors/wise_processor.rb +19 -0
  24. data/lib/ynab_convert/processors.rb +2 -2
  25. data/lib/ynab_convert/transformers/cleaners/cleaner.rb +17 -0
  26. data/lib/ynab_convert/transformers/cleaners/n26_cleaner.rb +13 -0
  27. data/lib/ynab_convert/transformers/cleaners/ubs_chequing_cleaner.rb +98 -0
  28. data/lib/ynab_convert/transformers/cleaners/ubs_credit_cleaner.rb +45 -0
  29. data/lib/ynab_convert/transformers/cleaners/wise_cleaner.rb +39 -0
  30. data/lib/ynab_convert/transformers/enhancers/enhancer.rb +20 -0
  31. data/lib/ynab_convert/transformers/enhancers/n26_enhancer.rb +74 -0
  32. data/lib/ynab_convert/transformers/enhancers/wise_enhancer.rb +87 -0
  33. data/lib/ynab_convert/transformers/formatters/example_formatter.rb +12 -0
  34. data/lib/ynab_convert/transformers/formatters/formatter.rb +91 -0
  35. data/lib/ynab_convert/transformers/formatters/n26_formatter.rb +19 -0
  36. data/lib/ynab_convert/transformers/formatters/ubs_chequing_formatter.rb +12 -0
  37. data/lib/ynab_convert/transformers/formatters/ubs_credit_formatter.rb +12 -0
  38. data/lib/ynab_convert/transformers/formatters/wise_formatter.rb +35 -0
  39. data/lib/ynab_convert/transformers.rb +18 -0
  40. data/lib/ynab_convert/validators/ynab4_row_validator.rb +83 -0
  41. data/lib/ynab_convert/validators.rb +9 -0
  42. data/lib/ynab_convert/version.rb +1 -1
  43. data/lib/ynab_convert.rb +4 -3
  44. data/ynab_convert.gemspec +4 -0
  45. metadata +91 -8
  46. data/lib/ynab_convert/processor/base.rb +0 -226
  47. data/lib/ynab_convert/processor/example.rb +0 -124
  48. data/lib/ynab_convert/processor/n26.rb +0 -70
  49. data/lib/ynab_convert/processor/revolut.rb +0 -103
  50. data/lib/ynab_convert/processor/ubs_chequing.rb +0 -137
  51. data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -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
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Processor
4
- # Processes CSV files from UBS Credit Cards Switzerland
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