ynab_convert 1.0.2 → 1.0.5
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -0
- data/.travis.yml +1 -1
- data/Gemfile.lock +5 -5
- data/Guardfile +1 -1
- data/README.md +3 -1
- data/bin/ynab_convert +0 -1
- data/lib/core_extensions/string.rb +1 -1
- data/lib/ynab_convert/processor/base.rb +70 -15
- data/lib/ynab_convert/processor/example.rb +1 -1
- data/lib/ynab_convert/processor/n26.rb +70 -0
- data/lib/ynab_convert/processor/ubs_chequing.rb +13 -3
- data/lib/ynab_convert/processor/ubs_credit.rb +1 -1
- data/lib/ynab_convert/version.rb +1 -1
- data/lib/ynab_convert.rb +0 -1
- data/ynab_convert.gemspec +10 -0
- metadata +9 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 58ad75b7f1c6b3093257d8ffb95068967324a621be48bc6e08063548d4a9f2bd
|
4
|
+
data.tar.gz: 70bc35076e068aece838b52853f5b7f7656199b67e5b2f3ca78a4aec66037ffb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 56621762f02d97515a0f3ce829d5b7bfe9ffb1fe862a6b0e92197ac2b555abe84102900f7eef28f67c7c0570efdf0bc3ea8005c8adb68f9c598a7f0fba94f6e9
|
7
|
+
data.tar.gz: 86a99a7d007c05b7aa3f6e6f3ed211ac009989bd8e31fefbef0db3d9a503dc765ecea825b9e1f6d237d2e26c7c59a455010f975183085037cf8bd2dcb95e8a04
|
data/.rubocop.yml
CHANGED
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.
|
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.
|
47
|
+
mini_portile2 (2.8.0)
|
48
48
|
nenv (0.3.0)
|
49
|
-
nokogiri (1.
|
50
|
-
mini_portile2 (~> 2.
|
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.
|
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
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
|
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
@@ -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
|
17
|
-
|
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
|
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
|
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
|
-
|
45
|
-
#
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
100
|
-
|
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
|
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
|
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
|
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
|
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)
|
data/lib/ynab_convert/version.rb
CHANGED
data/lib/ynab_convert.rb
CHANGED
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.
|
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-
|
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
|
-
|
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
|
-
|
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: '
|
252
|
+
version: '2.6'
|
250
253
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
251
254
|
requirements:
|
252
255
|
- - ">="
|