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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/publish.yml +36 -0
  3. data/.github/workflows/test.yml +31 -0
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -2
  6. data/Gemfile.lock +39 -14
  7. data/Guardfile +1 -29
  8. data/README.md +82 -7
  9. data/lib/ynab_convert/api_clients/api_client.rb +24 -0
  10. data/lib/ynab_convert/api_clients/currency_api.rb +66 -0
  11. data/lib/ynab_convert/documents/statements/example_statement.rb +16 -0
  12. data/lib/ynab_convert/documents/statements/n26_statement.rb +24 -0
  13. data/lib/ynab_convert/documents/statements/statement.rb +39 -0
  14. data/lib/ynab_convert/documents/statements/ubs_chequing_statement.rb +20 -0
  15. data/lib/ynab_convert/documents/statements/ubs_credit_statement.rb +19 -0
  16. data/lib/ynab_convert/documents/statements/wise_statement.rb +17 -0
  17. data/lib/ynab_convert/documents/ynab4_files/ynab4_file.rb +58 -0
  18. data/lib/ynab_convert/documents.rb +17 -0
  19. data/lib/ynab_convert/logger.rb +1 -1
  20. data/lib/ynab_convert/processors/example_processor.rb +24 -0
  21. data/lib/ynab_convert/processors/n26_processor.rb +26 -0
  22. data/lib/ynab_convert/processors/processor.rb +75 -0
  23. data/lib/ynab_convert/processors/ubs_chequing_processor.rb +21 -0
  24. data/lib/ynab_convert/processors/ubs_credit_processor.rb +17 -0
  25. data/lib/ynab_convert/processors/wise_processor.rb +19 -0
  26. data/lib/ynab_convert/processors.rb +2 -2
  27. data/lib/ynab_convert/transformers/cleaners/cleaner.rb +17 -0
  28. data/lib/ynab_convert/transformers/cleaners/n26_cleaner.rb +13 -0
  29. data/lib/ynab_convert/transformers/cleaners/ubs_chequing_cleaner.rb +98 -0
  30. data/lib/ynab_convert/transformers/cleaners/ubs_credit_cleaner.rb +45 -0
  31. data/lib/ynab_convert/transformers/cleaners/wise_cleaner.rb +39 -0
  32. data/lib/ynab_convert/transformers/enhancers/enhancer.rb +20 -0
  33. data/lib/ynab_convert/transformers/enhancers/n26_enhancer.rb +74 -0
  34. data/lib/ynab_convert/transformers/enhancers/wise_enhancer.rb +87 -0
  35. data/lib/ynab_convert/transformers/formatters/example_formatter.rb +12 -0
  36. data/lib/ynab_convert/transformers/formatters/formatter.rb +91 -0
  37. data/lib/ynab_convert/transformers/formatters/n26_formatter.rb +19 -0
  38. data/lib/ynab_convert/transformers/formatters/ubs_chequing_formatter.rb +12 -0
  39. data/lib/ynab_convert/transformers/formatters/ubs_credit_formatter.rb +12 -0
  40. data/lib/ynab_convert/transformers/formatters/wise_formatter.rb +35 -0
  41. data/lib/ynab_convert/transformers.rb +18 -0
  42. data/lib/ynab_convert/validators/ynab4_row_validator.rb +83 -0
  43. data/lib/ynab_convert/validators.rb +9 -0
  44. data/lib/ynab_convert/version.rb +1 -1
  45. data/lib/ynab_convert.rb +22 -3
  46. data/ynab_convert.gemspec +4 -0
  47. metadata +94 -10
  48. data/.travis.yml +0 -20
  49. data/lib/ynab_convert/processor/base.rb +0 -226
  50. data/lib/ynab_convert/processor/example.rb +0 -124
  51. data/lib/ynab_convert/processor/n26.rb +0 -70
  52. data/lib/ynab_convert/processor/revolut.rb +0 -103
  53. data/lib/ynab_convert/processor/ubs_chequing.rb +0 -137
  54. data/lib/ynab_convert/processor/ubs_credit.rb +0 -83
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Documents
4
+ module YNAB4Files
5
+ # Represents the YNAB4 formatted CSV data for importing into YNAB4
6
+ class YNAB4File
7
+ attr_reader :csv_export_options
8
+
9
+ def initialize(institution_name:, format: :flows)
10
+ @format = format
11
+ @institution_name = institution_name
12
+ @csv_export_options = {
13
+ converters: %i[numeric date],
14
+ force_quotes: true,
15
+ write_headers: true,
16
+ headers: headers
17
+ }
18
+ end
19
+
20
+ def update_dates(row)
21
+ date_index = 0
22
+ transaction_date = row[date_index]
23
+ unless transaction_date.is_a?(Date)
24
+ transaction_date = Date.parse(transaction_date)
25
+ end
26
+
27
+ update_start_date(transaction_date)
28
+ update_end_date(transaction_date)
29
+ end
30
+
31
+ def filename
32
+ from_date = @start_date.strftime('%Y%m%d')
33
+ to_date = @end_date.strftime('%Y%m%d')
34
+
35
+ "#{@institution_name.snake_case}_#{from_date}-#{to_date}_ynab4.csv"
36
+ end
37
+
38
+ private
39
+
40
+ def update_start_date(date)
41
+ @start_date = date if @start_date.nil? || date < @start_date
42
+ end
43
+
44
+ def update_end_date(date)
45
+ @end_date = date if @end_date.nil? || date > @end_date
46
+ end
47
+
48
+ def headers
49
+ base_headers = %w[Date Payee Memo]
50
+ extra_headers = %w[Outflow Inflow]
51
+
52
+ extra_headers = %w[Amount] if @format == :amounts
53
+
54
+ base_headers.concat(extra_headers)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Groups Statements and YNAB4File
4
+ module Documents
5
+ documents = %w[statement ynab4_file]
6
+
7
+ # Load all known Documents
8
+ documents.each do |d|
9
+ # Require the base classes first so that its children can find the parent
10
+ # class since files are otherwise loaded in alphabetical order
11
+ require File.join(__dir__, 'documents', "#{d}s", "#{d}.rb")
12
+
13
+ Dir[File.join(__dir__, 'documents', "#{d}s", '*.rb')].sort.each do |file|
14
+ require file
15
+ end
16
+ end
17
+ end
@@ -6,7 +6,7 @@ require 'logger'
6
6
  module YnabLogger
7
7
  def logger
8
8
  @logger unless @logger.nil?
9
- @logger ||= Logger.new(STDERR)
9
+ @logger ||= Logger.new($stderr)
10
10
  @logger.level = Logger::FATAL
11
11
  @logger.level = Logger::DEBUG if ENV['YNAB_CONVERT_DEBUG'] == 'true'
12
12
  @logger
@@ -0,0 +1,24 @@
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
+ # Example Processor
9
+ class Example < Processor
10
+ # @param filepath [String] path to the CSV file
11
+ def initialize(filepath:)
12
+ transformers = [
13
+ Transformers::Formatters::Example.new
14
+ ]
15
+ statement = Documents::Statements::Example.new(filepath: filepath)
16
+ ynab4_file = Documents::YNAB4Files::YNAB4File.new(
17
+ format: :flows, institution_name: statement.institution_name
18
+ )
19
+
20
+ super(statement: statement, ynab4_file: ynab4_file, transformers:
21
+ transformers)
22
+ end
23
+ end
24
+ end
@@ -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