ynab_convert 1.0.7 → 2.0.1

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +10 -2
  4. data/Gemfile.lock +37 -12
  5. data/Guardfile +1 -29
  6. data/README.md +82 -7
  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 +22 -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 -115
  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,115 +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
- # Transaction description is spread over 3 columns.
63
- # Moreover, UBS thought wise to append a bunch of junk information after
64
- # the transaction details within the third description field. *Most* of
65
- # this junk starts after the meaningful data and starts with ", OF",
66
- # ", ON", ", ESR", ", QRR", two digits then five groups of five digits
67
- # then ", TN" so we discard it; YNAB4 being unable to automatically
68
- # categorize new transactions at the same store/payee because the payee
69
- # always looks different (thanks to the variable nature of the appended
70
- # junk).
71
- # See `spec/fixtures/ubs_chequing/statement.csv` L2 and L18--22
72
-
73
- # rubocop:disable Metrics/LineLength
74
- junk_desc_regex = /,? (O[FN]|ESR|QRR|\d{2} \d{5} \d{5} \d{5} \d{5} \d{5}, TN)/
75
- # rubocop:enable Metrics/LineLength
76
-
77
- [
78
- row[headers[:payee_line_1]],
79
- row[headers[:payee_line_2]],
80
- row[headers[:payee_line_3]]
81
- ].join(' ').split(junk_desc_regex).first
82
- end
83
-
84
- def register_custom_converters
85
- CSV::Converters[:amounts] = lambda { |s|
86
- # Regex checks if string has only digits, apostrophes, and ends with a
87
- # dot and two digits
88
- amount_regex = /^[\d'?]+\.\d{2}$/
89
-
90
- if !s.nil? && s.match(amount_regex)
91
- amount = s.delete("'") .to_f
92
- logger.debug "Converted `#{s}' into amount `#{amount}'"
93
- return amount
94
- end
95
-
96
- logger.debug "Not an amount, not parsing `#{s.inspect}'"
97
- s
98
- }
99
-
100
- CSV::Converters[:transaction_dates] = lambda { |s|
101
- date_regex = /^\d{2}\.\d{2}\.\d{4}$/
102
-
103
- if !s.nil? && s.match(date_regex)
104
- parsed_date = Date.parse(s)
105
- logger.debug "Converted `#{s.inspect}' into date "\
106
- "`#{parsed_date}'"
107
- parsed_date
108
- else
109
- logger.debug "Not a date, not parsing #{s.inspect}"
110
- s
111
- end
112
- }
113
- end
114
- end
115
- 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