ynab_convert 1.0.6 → 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 +5 -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 -115
  51. data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ynab_convert/documents'
4
+ require 'ynab_convert/transformers'
5
+ require 'ynab_convert/processors/processor'
6
+
7
+ module Processors
8
+ # Processor for N26 statements
9
+ class N26 < Processor
10
+ # @param filepath [String] path to the CSV file
11
+ def initialize(filepath:)
12
+ transformers = [
13
+ Transformers::Cleaners::N26.new,
14
+ Transformers::Formatters::N26.new,
15
+ Transformers::Enhancers::N26.new
16
+ ]
17
+ statement = Documents::Statements::N26.new(filepath: filepath)
18
+ ynab4_file = Documents::YNAB4Files::YNAB4File.new(format: :amounts,
19
+ institution_name:
20
+ statement.institution_name)
21
+
22
+ super(statement: statement, ynab4_file: ynab4_file, transformers:
23
+ transformers)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ynab_convert/documents'
4
+ require 'ynab_convert/validators'
5
+ require 'csv'
6
+ require 'fileutils'
7
+
8
+ module Processors
9
+ # A processor instantiates the Documents and Transformers required to turn a
10
+ # Statement into a YNAB4File
11
+ class Processor
12
+ # @param statement [Documents::Statement] The CSV statement to process
13
+ # @param ynab4_file [Documents::YNAB4Files::YNAB4File] An instance of
14
+ # YNAB4File
15
+ # @param converters [Hash<Symbol, Proc>] A hash of converters to process
16
+ # each Statement row. The key is the name of the custom converter.
17
+ # The Proc receives the cell's content as a string and returns the
18
+ # converted value. See CSV::Converters.
19
+ # @param transformers [Array<Transformers::Transformer>] The Transformers
20
+ # to run in sequense
21
+ def initialize(statement:, ynab4_file:, transformers:, converters: {})
22
+ @statement = statement
23
+ @transformers = transformers
24
+ @validators = [::Validators::YNAB4Row]
25
+ @uid = rand(36**8).to_s(36)
26
+ @ynab4_file = ynab4_file
27
+ register_converters(converters)
28
+ end
29
+
30
+ def to_ynab!
31
+ convert
32
+ rename_temp_file
33
+ end
34
+
35
+ private
36
+
37
+ def convert
38
+ CSV.open(temp_filepath, 'wb',
39
+ **@ynab4_file.csv_export_options) do |ynab4_csv|
40
+ CSV.foreach(@statement.filepath, 'rb',
41
+ **@statement.csv_import_options) do |statement_row|
42
+ transformed_row = @transformers.reduce(statement_row) do |row, t|
43
+ t.run(row)
44
+ end
45
+
46
+ row_valid = @validators.reduce(true) do |is_valid, v|
47
+ is_valid && v.valid?(transformed_row)
48
+ end
49
+
50
+ if row_valid
51
+ @ynab4_file.update_dates(transformed_row)
52
+ ynab4_csv << transformed_row
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def register_converters(converters)
59
+ converters.each do |name, block|
60
+ CSV::Converters[name] = block
61
+ end
62
+ end
63
+
64
+ def temp_filepath
65
+ basename = File.basename(@statement.filepath, '.csv')
66
+ financial_institution = @statement.institution_name
67
+
68
+ "#{basename}_#{financial_institution.snake_case}_#{@uid}_ynab4.csv"
69
+ end
70
+
71
+ def rename_temp_file
72
+ FileUtils.mv(temp_filepath, @ynab4_file.filename)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Processors
4
+ # UBS Switzerland Chequing accounts processor
5
+ class UBSChequing < Processor
6
+ # @param filepath [String] path to the CSV file
7
+ def initialize(filepath:)
8
+ transformers = [
9
+ Transformers::Cleaners::UBSChequing.new,
10
+ Transformers::Formatters::UBSChequing.new
11
+ ]
12
+ statement = Documents::Statements::UBSChequing.new(filepath: filepath)
13
+ ynab4_file_options = { format: :flows,
14
+ institution_name: statement.institution_name }
15
+ ynab4_file = Documents::YNAB4Files::YNAB4File.new(ynab4_file_options)
16
+
17
+ super(statement: statement, ynab4_file: ynab4_file, transformers:
18
+ transformers)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Processors
4
+ # UBS Switzerland Credit Card accounts processor
5
+ class UBSCredit < Processor
6
+ def initialize(filepath:)
7
+ statement = Documents::Statements::UBSCredit.new(filepath: filepath)
8
+ ynab4_file_options = { institution_name: statement.institution_name }
9
+ ynab4_file = Documents::YNAB4Files::YNAB4File.new(ynab4_file_options)
10
+ transformers = [Transformers::Cleaners::UBSCredit.new,
11
+ Transformers::Formatters::UBSCredit.new]
12
+
13
+ super(statement: statement, ynab4_file: ynab4_file, transformers:
14
+ transformers)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Processors
4
+ # Wise card accounts processor
5
+ class Wise < Processor
6
+ def initialize(filepath:)
7
+ statement = Documents::Statements::Wise.new(filepath: filepath)
8
+ ynab4_file = Documents::YNAB4Files::YNAB4File.new(
9
+ institution_name: statement.institution_name, format: :amounts
10
+ )
11
+ transformers = [Transformers::Cleaners::Wise.new,
12
+ Transformers::Formatters::Wise.new,
13
+ Transformers::Enhancers::Wise.new]
14
+
15
+ super(statement: statement, ynab4_file: ynab4_file, transformers:
16
+ transformers)
17
+ end
18
+ end
19
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Base processor must be loaded first as all others inherit from it
4
- require 'ynab_convert/processor/base'
4
+ require 'ynab_convert/processors/processor'
5
5
 
6
6
  # Load all known processors
7
- Dir[File.join(__dir__, 'processor', '*.rb')].each { |file| require file }
7
+ Dir[File.join(__dir__, 'processors', '*.rb')].sort.each { |file| require file }
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Cleaners
5
+ # A Cleaner tidies up the Statement fields. For instance, it removes junk
6
+ # from the transaction descriptions/payee name, combines several
7
+ # columns into one to build the payee name, etc.
8
+ class Cleaner
9
+ # Cleans up a row
10
+ # @param row [CSV::Row] The row to parse
11
+ # @return [CSV::Row] The cleaned up row
12
+ def run(_row)
13
+ raise NotImplementedError, :run
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Cleaners
5
+ # Cleans N26 Statements
6
+ class N26 < Cleaner
7
+ def run(row)
8
+ # No cleaning required
9
+ row
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Cleaners
5
+ # UBS Switzerland Chequing accounts cleaner
6
+ class UBSChequing < Cleaner
7
+ HEADERS = {
8
+ date: 9,
9
+ payee_line1: 12,
10
+ payee_line2: 13,
11
+ payee_line3: 14,
12
+ outflow: 18,
13
+ inflow: 19
14
+ }.freeze
15
+
16
+ def run(row)
17
+ date = parse_transaction_date(row[HEADERS[:date]])
18
+ payee = clean_payee_lines(row)
19
+ outflow = parse_amount(row[HEADERS[:outflow]])
20
+ inflow = parse_amount(row[HEADERS[:inflow]])
21
+
22
+ cleaned_row = row.dup
23
+ cleaned_row[HEADERS[:date]] = date
24
+ # Put all the relevant payee data in the first line and empty the other
25
+ # two lines
26
+ cleaned_row[HEADERS[:payee_line1]] = payee
27
+ cleaned_row[HEADERS[:payee_line2]] = ''
28
+ cleaned_row[HEADERS[:payee_line3]] = ''
29
+ cleaned_row[HEADERS[:outflow]] = outflow
30
+ cleaned_row[HEADERS[:inflow]] = inflow
31
+
32
+ cleaned_row
33
+ end
34
+
35
+ private
36
+
37
+ def parse_transaction_date(date)
38
+ # Transaction dates are dd.mm.YYYY which Date#parse understands, but
39
+ # the CSV::Converter[:date] doesn't recognize it as it's not looking
40
+ # for dot separators.
41
+ return Date.parse(date) unless date.is_a?(Date) || date.nil?
42
+
43
+ date
44
+ end
45
+
46
+ def parse_amount(amount)
47
+ unless amount.nil? || amount.is_a?(Numeric)
48
+ return amount.delete("'").to_f
49
+ end
50
+
51
+ amount
52
+ end
53
+
54
+ def clean_payee_lines(row)
55
+ # Some lines are just bogus without any payee info. These will get
56
+ # weeded out down the line by the validators.
57
+ return row if row[HEADERS[:payee_line1]].nil? ||
58
+ row[HEADERS[:payee_line1]].empty?
59
+
60
+ # Transaction description is spread over 3 columns.
61
+ # There are two types of entries:
62
+ # 1. only the first column contains data
63
+ # 2. all three columns contain data, most of it junk, with only cols 2
64
+ # and 3 having meaningful data
65
+ # Cleaning them up means dropping the first column if there is anything
66
+ # in the other columns;
67
+ # Then removing the rest of the junk appended after the worthwhile data;
68
+ # Finally removing the CARD 00000000-0 0000 at the beginning of debit
69
+ # card payment entries
70
+ raw_payee_line = [row[HEADERS[:payee_line2]],
71
+ row[HEADERS[:payee_line3]]].join(' ')
72
+ if row[HEADERS[:payee_line2]].nil?
73
+ raw_payee_line = row[HEADERS[:payee_line1]]
74
+ end
75
+
76
+ # UBS thought wise to append a bunch of junk information after the
77
+ # transaction details within the third description field. *Most* of
78
+ # this junk starts after the meaningful data and starts with ", OF", ",
79
+ # ON", ", ESR", ", QRR", two digits then five groups of five digits
80
+ # then ", TN" so we discard it; YNAB4 being unable to automatically
81
+ # categorize new transactions at the same store/payee if the payee
82
+ # always looks different (thanks to the variable nature of the appended
83
+ # junk).
84
+
85
+ # rubocop:disable Layout/LineLength
86
+ junk_desc_regex = /,? (O[FN]|ESR|QRR|\d{2} \d{5} \d{5} \d{5} \d{5} \d{5}, TN).*/
87
+ # rubocop:enable Layout/LineLength
88
+
89
+ # Of course, it wouldn't be complete with more junk information at the
90
+ # beginning of *some* lines (debit card payments) in the following
91
+ # form: "CARD 00000000-0 0000"
92
+ debit_card_junk_regex = /CARD \d{8}-\d \d{4} /
93
+
94
+ raw_payee_line.sub(junk_desc_regex, '').sub(debit_card_junk_regex, '')
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Cleaners
5
+ # UBS Switzerland Credit Card accounts cleaner
6
+ class UBSCredit < Cleaner
7
+ HEADERS = {
8
+ date: 3,
9
+ payee: 4,
10
+ outflow: 10,
11
+ inflow: 11
12
+ }.freeze
13
+
14
+ def run(row)
15
+ date = parse_transaction_date(row[HEADERS[:date]])
16
+ outflow = parse_amount(row[HEADERS[:outflow]])
17
+ inflow = parse_amount(row[HEADERS[:inflow]])
18
+
19
+ cleaned_row = row.dup
20
+ cleaned_row[HEADERS[:date]] = date
21
+ cleaned_row[HEADERS[:outflow]] = outflow
22
+ cleaned_row[HEADERS[:inflow]] = inflow
23
+
24
+ cleaned_row
25
+ end
26
+
27
+ def parse_transaction_date(date)
28
+ # Transaction dates are dd.mm.YYYY which Date#parse understands, but
29
+ # the CSV::Converter[:date] doesn't recognize it as it's not looking
30
+ # for dot separators.
31
+ return Date.parse(date) unless date.is_a?(Date) || date.nil?
32
+
33
+ date
34
+ end
35
+
36
+ def parse_amount(amount)
37
+ unless amount.nil? || amount.is_a?(Numeric)
38
+ return amount.delete("'").to_f
39
+ end
40
+
41
+ amount
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Cleaners
5
+ # Wise card accounts cleaner
6
+ class Wise < Cleaner
7
+ def run(row)
8
+ date_index = 1
9
+ payee_index = 13
10
+ cleaned_row = row.dup
11
+ date = parse_date(cleaned_row[date_index])
12
+ payee = clean_payee(cleaned_row[payee_index])
13
+
14
+ cleaned_row[date_index] = date
15
+ cleaned_row[payee_index] = payee
16
+
17
+ cleaned_row
18
+ end
19
+
20
+ # turn String "dd-mm-YYYY" into a Date since the
21
+ # CSV::Transformers[:date] doesn't recognize the dd-mm-YYYY format
22
+ # @param date [String] the date string to parse (format "dd-mm-YYYY")
23
+ # @return Date the parsed date string
24
+ def parse_date(date)
25
+ Date.parse(date)
26
+ end
27
+
28
+ # The payee data can include some junk (mostly for PayPal/Ebay
29
+ # transactions)
30
+ # @param payee [String] the payee string to clean
31
+ # @return String the payee string without the junk
32
+ def clean_payee(payee)
33
+ return payee if payee.nil?
34
+
35
+ payee.gsub(/O\*\d{2}-\d{5}-\d{5}\s/, '')
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Enhancers
5
+ # An Enhancer parses YNAB4 rows (Date, Payee, Memo, Amount or Inflow and
6
+ # Outflow) and augments the data therein.
7
+ # A typical case would be converting from one currency to the user's YNAB
8
+ # base currency when the Statement is in a different currency (see
9
+ # n26_enhancer.rb for an example)
10
+ class Enhancer
11
+ def initialize; end
12
+
13
+ # @param _row [CSV::Row] The row to enhance
14
+ # @return [CSV::Row] The enhanced row
15
+ def run(_row)
16
+ raise NotImplementedError, :run
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ynab_convert/transformers/enhancers/enhancer'
4
+ require 'ynab_convert/api_clients/currency_api'
5
+
6
+ module Transformers
7
+ module Enhancers
8
+ # Converts amounts from EUR to CHF
9
+ class N26 < Enhancer
10
+ def initialize
11
+ @api_client = APIClients::CurrencyAPI.new
12
+ super()
13
+ end
14
+
15
+ # @param row [CSV::Row] The YNAB4 formatted row to enhance
16
+ # @return [CSV::Row] A YNAB4 formatted row with amounts converted
17
+ # @note The currency is hardcoded to CHF for now
18
+ # @note Takes a YNAB4 formatted CSV::Row, with the transaction's currency
19
+ # in the Memo field
20
+ def run(row)
21
+ amount_index = 3
22
+ amount = row[amount_index]
23
+
24
+ # Transaction currency should be in the memo field
25
+ base_currency = row[2].to_sym
26
+
27
+ date = row[0]
28
+
29
+ # TODO: Implement a config file for currencies
30
+ converted_amount = convert_amount(amount: amount,
31
+ base_currency: base_currency,
32
+ target_currency: :chf,
33
+ date: date)
34
+
35
+ enhanced_row = row.dup
36
+ enhanced_row[amount_index] = converted_amount
37
+ # Put original amount and currency in Memo
38
+ memo_line = 'Original amount: '\
39
+ "#{format('%<amount>.2f', amount: amount)} #{base_currency}"
40
+ enhanced_row[2] = memo_line
41
+ enhanced_row
42
+ end
43
+
44
+ private
45
+
46
+ # @param base_currency [Symbol] The ISO symbol of the amount's
47
+ # currency (base currency)
48
+ # @param target_currency [Symbol] The ISO symbol of the currency to
49
+ # convert the amount to (target currency)
50
+ # @param date [Date] The date on which to fetch the rate for conversion
51
+ # @return [Float] The conversion rate for amount_currency into CHF
52
+ def get_rate_for_date(base_currency:, target_currency:, date:)
53
+ rates = @api_client.historical(base_currency: base_currency, date: date)
54
+ rates[target_currency]
55
+ end
56
+
57
+ # @param base_currency [Symbol] The ISO symbol of the amount's
58
+ # currency
59
+ # @param target_currency [Symbol] The ISO symbol of the currency to
60
+ # convert the amount to (target currency)
61
+ # @param amount [Numeric] The amount in amount_currency to convert
62
+ # @param date [Date] The date on which to fetch the rate for conversion
63
+ # @return [Numeric] The converted amount
64
+ def convert_amount(amount:, base_currency:, target_currency:, date:)
65
+ rate = get_rate_for_date(base_currency: base_currency,
66
+ target_currency: target_currency,
67
+ date: date)
68
+
69
+ # format('%<converted>.2f', converted: amount * rate)
70
+ (amount * rate).round(2)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Enhancers
5
+ # Wise card accounts enhancer
6
+ class Wise < Enhancer
7
+ def initialize
8
+ @api_client = APIClients::CurrencyAPI.new
9
+ @indices = { date: 0, amount: 3, memo: 2 }
10
+
11
+ super()
12
+ end
13
+
14
+ def run(ynab_row)
15
+ amount = ynab_row[@indices[:amount]]
16
+ date = ynab_row[@indices[:date]]
17
+ metadata = deserialize_metadata(ynab_row[@indices[:memo]])
18
+ enhanced_row = ynab_row.dup
19
+
20
+ # Some transactions, like topups, don't have require conversion
21
+ unless metadata[:original_amount].nil?
22
+ converted_amount = convert_amount(
23
+ amount: amount,
24
+ base_currency: metadata[:amount_currency],
25
+ target_currency: :chf,
26
+ date: date
27
+ )
28
+ end
29
+
30
+ # Inlude the original transaction amount in the original currency in
31
+ # the Memo field if conversion was performed
32
+ unless converted_amount.nil?
33
+ enhanced_row[@indices[:amount]] = converted_amount
34
+ # Put original amount and currency in Memo
35
+ enhanced_row[@indices[:memo]] = 'Original amount: '\
36
+ "#{metadata[:original_amount]}"
37
+ end
38
+
39
+ # If not, clear the metadata from the Memo field
40
+ enhanced_row[@indices[:memo]] = '' if converted_amount.nil?
41
+
42
+ enhanced_row
43
+ end
44
+
45
+ private
46
+
47
+ # @param memo [String] string to deserialize (format is
48
+ # `<amount_currency>,<original_amount>`, as formatted by the
49
+ # wise_formatter.rb)
50
+ def deserialize_metadata(memo)
51
+ # metadata is `<amount_currency>,<original_amount>`
52
+ split = memo.split(',')
53
+
54
+ # amount_currency is a String but the CurrencyAPI needs it to be a
55
+ # Symbol
56
+ { amount_currency: split[0].to_sym, original_amount: split[1] }
57
+ end
58
+
59
+ # @param base_currency [Symbol] The ISO symbol of the amount's
60
+ # currency (base currency)
61
+ # @param target_currency [Symbol] The ISO symbol of the currency to
62
+ # convert the amount to (target currency)
63
+ # @param date [Date] The date on which to fetch the rate for conversion
64
+ # @return [Float] The conversion rate for amount_currency into CHF
65
+ def get_rate_for_date(base_currency:, target_currency:, date:)
66
+ rates = @api_client.historical(base_currency: base_currency, date: date)
67
+ rates[target_currency]
68
+ end
69
+
70
+ # @param base_currency [Symbol] The ISO symbol of the amount's
71
+ # currency
72
+ # @param target_currency [Symbol] The ISO symbol of the currency to
73
+ # convert the amount to (target currency)
74
+ # @param amount [Numeric] The amount in amount_currency to convert
75
+ # @param date [Date] The date on which to fetch the rate for conversion
76
+ # @return [Numeric] The converted amount
77
+ def convert_amount(amount:, base_currency:, target_currency:, date:)
78
+ rate = get_rate_for_date(base_currency: base_currency,
79
+ target_currency: target_currency,
80
+ date: date)
81
+
82
+ # format('%<converted>.2f', converted: amount * rate)
83
+ (amount * rate).round(2)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Formatters
5
+ # Example Formatter
6
+ class Example < Formatter
7
+ def initialize
8
+ super({ date: [0], payee: [2], outflow: [3], inflow: [4] })
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transformers
4
+ module Formatters
5
+ # Formats Statements rows into YNAB4 rows (Date, Payee, Memo, Amount or
6
+ # Outflow and Inflow.)
7
+ class Formatter
8
+ # @param [Hash] headers_indices the indices at which to find each
9
+ # header's name
10
+ # @option headers_indices [Array<Numeric>] :date transaction date
11
+ # @option headers_indices [Array<Numeric>] :payee transaction
12
+ # payee/description
13
+ # @option headers_indices [Array<Numeric>] :memo transaction memo or
14
+ # currency if currency conversion will be performed
15
+ # @option headers_indices [Array<Numeric>] :amount transaction amount (if
16
+ # Statement is using the :amounts format)
17
+ # @option headers_indices [Array<Numeric>] :outflow transaction outflow
18
+ # (if using the :flows format)
19
+ # @option headers_indices [Array<Numeric>] :inflow transaction inflow (if
20
+ # using the :flows format)
21
+ def initialize(**headers_indices)
22
+ default_values = {
23
+ memo: [] # The Memo field tends to be empty for most institutions
24
+ }
25
+
26
+ @format = :flows
27
+ unless headers_indices[:amount].nil? || headers_indices[:amount].empty?
28
+ @format = :amounts
29
+ end
30
+ @headers_indices = default_values.merge(headers_indices)
31
+ end
32
+
33
+ # Turns CSV rows into YNAB4 rows (Date, Payee, Memo, Amount or Outflow and
34
+ # Inflow)
35
+ # @param row [CSV::Row] The CSV row to parse
36
+ # @return [Array<String>] The YNAB4 formatted row
37
+ def run(row)
38
+ ynab_row = [date(row), payee(row), memo(row)]
39
+
40
+ if @format == :amounts
41
+ ynab_row << amount(row)
42
+ else
43
+ ynab_row << outflow(row)
44
+ ynab_row << inflow(row)
45
+ end
46
+
47
+ ynab_row
48
+ end
49
+
50
+ # Processes columns for each row. Based on the method name that is called,
51
+ # it will extract the corresponding column (field).
52
+ # @note In more complex cases, some heuristics are required to format some
53
+ # of the columns. In that case, any of the aliased #field methods
54
+ # (#date, #payee, #memo, #amount, #outflow, #inflow) can be
55
+ # overridden in the child.
56
+ # @param row [CSV::Row] The row to process
57
+ # @return [String] The corresponding field(s)
58
+ def field(row)
59
+ # Figure out the aliased name the method was called with, to derive
60
+ # which field to return from the row.
61
+ requested_field = __callee__.to_sym
62
+ assembled_field = @headers_indices[requested_field].reduce([]) do
63
+ |fields_data, i|
64
+ fields_data << row[i]
65
+ end
66
+
67
+ # Avoid turning Dates and Numerics back to strings
68
+ # If the assembled_field isn't a composite from several Statement
69
+ # fields, there is no need to join(' ') and turn it into a String
70
+ formatted_field = assembled_field[0]
71
+ if assembled_field.length > 1
72
+ formatted_field = assembled_field.join(' ')
73
+ end
74
+
75
+ # Avoid "nil" values in the output
76
+ return '' if formatted_field.nil?
77
+
78
+ formatted_field
79
+ end
80
+
81
+ # Create alias names for the field method. This allows the function to
82
+ # figure out which field to extract from its method name.
83
+ alias date field
84
+ alias payee field
85
+ alias memo field
86
+ alias amount field
87
+ alias outflow field
88
+ alias inflow field
89
+ end
90
+ end
91
+ end