ynab_convert 1.0.2 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e707b36e18c8b57f7b517bf6f1f29d769e22089316448da062165c35e5fb713
4
- data.tar.gz: ee5dbf483c6252085860166c6371fd7d6ba75b91d90dc17a74a9a68ddff80026
3
+ metadata.gz: 58ad75b7f1c6b3093257d8ffb95068967324a621be48bc6e08063548d4a9f2bd
4
+ data.tar.gz: 70bc35076e068aece838b52853f5b7f7656199b67e5b2f3ca78a4aec66037ffb
5
5
  SHA512:
6
- metadata.gz: 44f457015648d06a43c98da0622ae12de10eb84025636677b044020d98aa82dca85c2968f9b924541dd8260002a8e727bef22cf60515d6dc17e345d8e06cf0e5
7
- data.tar.gz: 0fadae67422faf27caa356890121600879416545043c240ef951a3bc2c9caf9a8af544f25978a50e3e479994eb020e008e99c32a99c76363d239e82d09037fcb
6
+ metadata.gz: 56621762f02d97515a0f3ce829d5b7bfe9ffb1fe862a6b0e92197ac2b555abe84102900f7eef28f67c7c0570efdf0bc3ea8005c8adb68f9c598a7f0fba94f6e9
7
+ data.tar.gz: 86a99a7d007c05b7aa3f6e6f3ed211ac009989bd8e31fefbef0db3d9a503dc765ecea825b9e1f6d237d2e26c7c59a455010f975183085037cf8bd2dcb95e8a04
data/.rubocop.yml CHANGED
@@ -1,6 +1,11 @@
1
1
  ---
2
+ # See https://github.com/rubocop/rubocop/blob/master/config/default.yml for all
3
+ # options
2
4
  require: rubocop-rake
3
5
 
6
+ AllCops:
7
+ DisplayCopNames: true
8
+
4
9
  Metrics/LineLength:
5
10
  Exclude:
6
11
  - ynab_convert.gemspec
data/.travis.yml CHANGED
@@ -3,12 +3,12 @@ language: ruby
3
3
  cache: bundler
4
4
  rvm:
5
5
  - 2.6
6
+ - 2.7
6
7
  before_install: gem install bundler
7
8
 
8
9
  jobs:
9
10
  include:
10
11
  - stage: test
11
- rvm: 2.6
12
12
  script: echo "Running tests for $(ruby -v)..." && bundle exec rake ci
13
13
  - stage: gem release
14
14
  rvm: 2.6
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ynab_convert (1.0.2)
4
+ ynab_convert (1.0.5)
5
5
  i18n
6
6
  slop
7
7
 
@@ -44,10 +44,10 @@ GEM
44
44
  rb-inotify (~> 0.9, >= 0.9.10)
45
45
  lumberjack (1.0.13)
46
46
  method_source (0.9.2)
47
- mini_portile2 (2.6.1)
47
+ mini_portile2 (2.8.0)
48
48
  nenv (0.3.0)
49
- nokogiri (1.12.5)
50
- mini_portile2 (~> 2.6.1)
49
+ nokogiri (1.13.3)
50
+ mini_portile2 (~> 2.8.0)
51
51
  racc (~> 1.4)
52
52
  notiffany (0.1.3)
53
53
  nenv (~> 0.1)
@@ -61,7 +61,7 @@ GEM
61
61
  pry-byebug (3.7.0)
62
62
  byebug (~> 11.0)
63
63
  pry (~> 0.10)
64
- racc (1.5.2)
64
+ racc (1.6.0)
65
65
  rainbow (3.0.0)
66
66
  rake (13.0.1)
67
67
  rb-fsevent (0.10.3)
data/Guardfile CHANGED
@@ -74,7 +74,7 @@ group :red_green_refactor, halt_on_fail: true do
74
74
  end
75
75
  end
76
76
 
77
- guard :rubocop, cli: ['--auto-correct'] do
77
+ guard :rubocop, cli: ['--auto-correct', '--display-cop-names'] do
78
78
  watch('Gemfile')
79
79
  watch('Rakefile')
80
80
  watch('bin/convert')
data/README.md CHANGED
@@ -27,6 +27,7 @@ latest one on 2019-12-01.
27
27
  `-i` argument | Institution's full name | Institution's website | Remarks
28
28
  ---|---|---|---
29
29
  `example` | Example Bank | N/A | Reference processor implementation, not a real institution
30
+ `n26` | N26 | [n26.com](n26.com) | N26 CSV statements
30
31
  `revolut` | Revolut Ltd | [revolut.com](https://www.revolut.com/) | The processor isn't aware of currencies. Make sure the statements processed with `revolut` are in the same currency that your YNAB is in
31
32
  `ubs_chequing` | UBS Switzerland (private banking) | [ubs.ch](https://ubs.ch) | Private chequing and joint accounts
32
33
  `ubs_credit` | UBS Switzerland (credit cards) | [ubs.ch](https://ubs.ch) | Both MasterCard and Visa
@@ -44,7 +45,8 @@ https://github.com/coaxial/ynab_convert.
44
45
 
45
46
  ### Enable debug output
46
47
 
47
- Run `ynab_convert` with `YNAB_CONVERT_DEBUG=true`, or use the rake task `spec:debug`. Debug logging goes to STDERR.
48
+ Run `ynab_convert` with `YNAB_CONVERT_DEBUG=true`, or use the rake task
49
+ `spec:debug`. Debug logging goes to STDERR.
48
50
 
49
51
  ### Adding a new financial institution
50
52
 
data/bin/ynab_convert CHANGED
@@ -9,7 +9,6 @@ require 'ynab_convert/processors'
9
9
  require 'ynab_convert/logger'
10
10
  require 'ynab_convert/error'
11
11
  require 'slop/symbol'
12
- require 'pry'
13
12
 
14
13
  # Add mixins and run converter
15
14
  class Convert
@@ -6,7 +6,7 @@ module CoreExtensions
6
6
  # Adds convenience methods
7
7
  module Inflections
8
8
  def snake_case
9
- downcase.tr(' ', '_').gsub(/[^a-z_]/, '')
9
+ downcase.tr(' ', '_').gsub(/[^a-z_0-9]/, '')
10
10
  end
11
11
 
12
12
  def camel_case
@@ -5,7 +5,8 @@ require 'csv'
5
5
  require 'ynab_convert/logger'
6
6
 
7
7
  module Processor
8
- # Base class for a Processor, all processors must inherit from it
8
+ # Base class for a Processor, all processors must inherit from it.
9
+
9
10
  # rubocop:disable Metrics/ClassLength
10
11
  class Base
11
12
  include YnabLogger
@@ -13,13 +14,30 @@ module Processor
13
14
 
14
15
  attr_reader :loader_options
15
16
 
16
- # @option opts [String] :file Path to the CSV file to process
17
- def initialize(opts)
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
+
18
27
  logger.debug "Initializing processor with options: `#{opts.to_h}'"
19
28
  raise ::Errno::ENOENT unless File.exist? opts[:file]
20
29
 
21
30
  @file = opts[:file]
22
- @headers = { transaction_date: nil, payee: nil, debit: nil, credit: nil }
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
23
41
  end
24
42
 
25
43
  def to_ynab!
@@ -38,15 +56,41 @@ module Processor
38
56
 
39
57
  attr_accessor :statement_from, :statement_to, :headers
40
58
 
41
- def inflow_or_outflow_missing?(row)
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)
42
68
  inflow_index = 3
43
69
  outflow_index = 4
44
- # If there is neither inflow and outflow values, or their value is 0,
45
- # then the row is not valid to YNAB4
46
- (row[inflow_index].nil? || row[inflow_index].empty? ||
47
- row[inflow_index] == '0.00') &&
48
- (row[outflow_index].nil? || row[outflow_index].empty? ||
49
- row[outflow_index] == '0.00')
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
50
94
  end
51
95
 
52
96
  def skip_row(row)
@@ -96,14 +140,15 @@ module Processor
96
140
  def convert!
97
141
  logger.debug "Will write to `#{temp_filename}'"
98
142
 
99
- CSV.open(temp_filename, 'wb', output_options) do |converted|
100
- CSV.foreach(@file, 'rb', loader_options) do |row|
143
+ logger.debug(loader_options)
144
+ CSV.open(temp_filename, 'wb', **output_options) do |converted|
145
+ CSV.foreach(@file, 'rb', **loader_options) do |row|
101
146
  logger.debug "Parsing row: `#{row.to_h}'"
102
147
  # Some rows don't contain valid or useful data
103
148
  catch :skip_row do
104
149
  extract_header_names(row)
105
150
  ynab_row = transformers(row)
106
- if inflow_or_outflow_missing?(ynab_row) ||
151
+ if amounts_missing?(ynab_row) ||
107
152
  transaction_date_missing?(ynab_row)
108
153
  logger.debug 'Empty row, skipping it'
109
154
  skip_row(row)
@@ -151,7 +196,17 @@ module Processor
151
196
  end
152
197
 
153
198
  def ynab_headers
154
- %w[Date Payee Memo Outflow Inflow]
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
155
210
  end
156
211
 
157
212
  def output_options
@@ -14,7 +14,7 @@ module Processor
14
14
  # if it's redundant. For instance, this parser is for "Example Bank" but it's
15
15
  # named "example.rb", its corresponding spec is
16
16
  # "spec/example_processor_spec.rb" and its fixture would be
17
- # "spec/fixtures/example.csv"
17
+ # "spec/fixtures/example/statement.csv"
18
18
  class Example < Processor::Base
19
19
  # @option options [String] :file Path to the CSV file to process
20
20
  def initialize(options)
@@ -0,0 +1,70 @@
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,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Processor
4
- # Processes CSV files from UBS Personal Banking Switzerland (French)
4
+ # Processes CSV files from UBS Personal Banking Switzerland
5
5
  class UbsChequing < Processor::Base
6
6
  # @option options [String] :file Path to the CSV file to process
7
7
  def initialize(options)
@@ -59,12 +59,22 @@ module Processor
59
59
  end
60
60
 
61
61
  def transaction_payee(row)
62
- # Transaction description is spread over 3 columns
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", two digits then five groups of five digits then ", TN"
67
+ # so we discard it; YNAB4 being unable to automatically categorize new
68
+ # transactions at the same store/payee because the payee always looks
69
+ # different (thanks to the variable nature of the appended junk).
70
+ # See `spec/fixtures/ubs_chequing/statement.csv` L18 and L2.
71
+ junk_desc_regex = /, (O[FN]|ESR|\d{2} \d{5} \d{5} \d{5} \d{5} \d{5}, TN)/
72
+
63
73
  [
64
74
  row[headers[:payee_line_1]],
65
75
  row[headers[:payee_line_2]],
66
76
  row[headers[:payee_line_3]]
67
- ].join(' ')
77
+ ].join(' ').split(junk_desc_regex).first
68
78
  end
69
79
 
70
80
  def register_custom_converters
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Processor
4
- # Processes CSV files from UBS Credit Cards Switzerland (French)
4
+ # Processes CSV files from UBS Credit Cards Switzerland
5
5
  class UbsCredit < Processor::Base
6
6
  # @option options [String] :file Path to the CSV file to process
7
7
  def initialize(options)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YnabConvert
4
- VERSION = '1.0.2'
4
+ VERSION = '1.0.5'
5
5
  end
data/lib/ynab_convert.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'ynab_convert/version'
4
4
  require 'slop'
5
- require 'pry'
6
5
  require 'ynab_convert/logger'
7
6
  require 'core_extensions/string.rb'
8
7
 
data/ynab_convert.gemspec CHANGED
@@ -14,6 +14,16 @@ Gem::Specification.new do |spec|
14
14
  spec.homepage = 'https://github.com/coaxial/ynab_convert'
15
15
  spec.license = 'MIT'
16
16
 
17
+ spec.required_ruby_version = '~> 2.6'
18
+
19
+ spec.metadata = {
20
+ 'bug_tracker_uri' => 'https://github.com/coaxial/ynab_convert/issues',
21
+ 'documentation_uri' => 'https://rubydoc.info/github/coaxial/ynab_convert/master',
22
+ 'homepage_uri' => 'https://github.com/coaxial/ynab_convert',
23
+ 'source_code_uri' => 'https://github.com/coaxial/ynab_convert'
24
+ }
25
+ spec.post_install_message = 'Happy budgetting!'
26
+
17
27
  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
28
  # to allow pushing to a single host or delete this section to allow pushing to any host.
19
29
  if spec.respond_to?(:metadata)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ynab_convert
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - coaxial
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-24 00:00:00.000000000 Z
11
+ date: 2022-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -225,6 +225,7 @@ files:
225
225
  - lib/ynab_convert/logger.rb
226
226
  - lib/ynab_convert/processor/base.rb
227
227
  - lib/ynab_convert/processor/example.rb
228
+ - lib/ynab_convert/processor/n26.rb
228
229
  - lib/ynab_convert/processor/revolut.rb
229
230
  - lib/ynab_convert/processor/ubs_chequing.rb
230
231
  - lib/ynab_convert/processor/ubs_credit.rb
@@ -235,18 +236,20 @@ homepage: https://github.com/coaxial/ynab_convert
235
236
  licenses:
236
237
  - MIT
237
238
  metadata:
238
- allowed_push_host: https://rubygems.org
239
+ bug_tracker_uri: https://github.com/coaxial/ynab_convert/issues
240
+ documentation_uri: https://rubydoc.info/github/coaxial/ynab_convert/master
239
241
  homepage_uri: https://github.com/coaxial/ynab_convert
240
242
  source_code_uri: https://github.com/coaxial/ynab_convert
241
- post_install_message:
243
+ allowed_push_host: https://rubygems.org
244
+ post_install_message: Happy budgetting!
242
245
  rdoc_options: []
243
246
  require_paths:
244
247
  - lib
245
248
  required_ruby_version: !ruby/object:Gem::Requirement
246
249
  requirements:
247
- - - ">="
250
+ - - "~>"
248
251
  - !ruby/object:Gem::Version
249
- version: '0'
252
+ version: '2.6'
250
253
  required_rubygems_version: !ruby/object:Gem::Requirement
251
254
  requirements:
252
255
  - - ">="