ynap 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ class Float
2
+ def to_ynab
3
+ (BigDecimal(self.to_s) * 1000).to_i
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class Integer
2
+ def to_plaid
3
+ self.to_f / 1000
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ require "plaid"
2
+
3
+ class Plaid::Models::Transaction
4
+ # Includes a negative amount for easier comparison with YNAB transactions
5
+ def description
6
+ [date, "#{(-amount).to_s.rjust(12)} #{iso_currency_code}", name].join(" - ")
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ require "ynab"
2
+
3
+ class YNAB::SaveTransaction
4
+ # Includes a decimal amount for easier comparison with Plaid transactions
5
+ def description
6
+ [date, amount.to_plaid.to_s.rjust(12), payee_name, memo].join(" - ")
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ require "ynab"
2
+
3
+ class YNAB::TransactionDetail
4
+ # Includes a decimal amount for easier comparison with Plaid transactions
5
+ def description
6
+ [date, amount.to_plaid.to_s.rjust(12), payee_name, memo].join(" - ")
7
+ end
8
+ end
@@ -0,0 +1,62 @@
1
+ require 'date'
2
+ require 'ynap/models/bridge_record.rb'
3
+
4
+ class Account < BridgeRecord
5
+ TRANSACTIONS_HORIZON = 30
6
+
7
+ attr_reader :plaid_id, :start_date, :ynab_id
8
+
9
+ def initialize(plaid_id:, plaid_access_token:, ynab_id:, start_date: nil)
10
+ super(plaid_access_token)
11
+ @plaid_id = plaid_id
12
+ @ynab_id = ynab_id
13
+ @start_date = Date.parse(start_date) unless start_date.nil?
14
+ @to_date = Date.today
15
+ @from_date = @start_date || (@to_date - TRANSACTIONS_HORIZON)
16
+ end
17
+
18
+ def description
19
+ reconciled = reconciled? ? "✅" : "❌"
20
+ "#{ynab_account.name}: #{plaid_balance} P#{reconciled}Y #{ynab_balance.to_plaid} #{plaid_account.balances.iso_currency_code}"
21
+ end
22
+
23
+ def balances
24
+ { plaid: plaid_balance, ynab: ynab_balance.to_plaid }
25
+ end
26
+
27
+ def reconciled?
28
+ plaid_balance.to_ynab == ynab_balance
29
+ end
30
+
31
+ # Plaid
32
+
33
+ def plaid_accounts
34
+ @plaid_accounts ||= plaid_client.accounts.balance.get(plaid_access_token).accounts
35
+ end
36
+
37
+ def plaid_account
38
+ @plaid_account ||= plaid_accounts.find { |account| account.account_id == plaid_id }
39
+ end
40
+
41
+ def plaid_balance
42
+ @plaid_balance ||= plaid_account.balances.available
43
+ end
44
+
45
+ def plaid_transactions
46
+ @plaid_transactions ||= plaid_client.transactions.get(plaid_access_token, @from_date, @to_date, account_ids: [plaid_id]).transactions
47
+ end
48
+
49
+ # YNAB
50
+
51
+ def ynab_account
52
+ @ynab_account ||= ynab_client.accounts.get_account_by_id(Ynap.config.dig(:ynab, :budget_id), ynab_id).data.account
53
+ end
54
+
55
+ def ynab_balance
56
+ @ynab_balance ||= ynab_account.balance
57
+ end
58
+
59
+ def ynab_transactions
60
+ @ynab_transactions ||= ynab_client.transactions.get_transactions_by_account(Ynap.config.dig(:ynab, :budget_id), ynab_id, since_date: @from_date).data.transactions.reverse
61
+ end
62
+ end
@@ -0,0 +1,145 @@
1
+ require 'plaid'
2
+ require 'yaml'
3
+ require 'ynap/extensions/float.rb'
4
+ require 'ynap/models/account.rb'
5
+ require 'ynap/models/bridge_record.rb'
6
+
7
+ class Bank < BridgeRecord
8
+ attr_reader :id, :name, :plaid_access_token, :accounts, :result
9
+ attr_accessor :transactions_horizon
10
+
11
+ def initialize(id:, name:, plaid_access_token:, accounts: [])
12
+ super(plaid_access_token)
13
+ @id = id
14
+ @name = name
15
+ @plaid_access_token = plaid_access_token
16
+ @accounts = accounts.map { |params| Account.new params.merge(plaid_access_token: plaid_access_token) }
17
+ @transactions_horizon = Account::TRANSACTIONS_HORIZON
18
+ end
19
+
20
+ def self.find(id)
21
+ new Ynap.bank_config(id)
22
+ end
23
+
24
+ def self.all
25
+ Ynap.config[:banks].map do |params|
26
+ new params
27
+ end
28
+ end
29
+
30
+ def self.accounts_descriptions
31
+ all.map(&:accounts_descriptions).flatten.join("\n")
32
+ end
33
+
34
+ def self.payees(with_memos: false)
35
+ with_memos ? payees_memos : all.map(&:payees).flatten.uniq
36
+ end
37
+
38
+ def self.payees_memos
39
+ all.map(&:payees_memos).flatten.uniq
40
+ end
41
+
42
+ #
43
+ # Plaidist
44
+ #
45
+
46
+ # Accounts
47
+
48
+ def all_plaid_accounts
49
+ @all_plaid_accounts ||= plaid_client.accounts.get(plaid_access_token).accounts
50
+ end
51
+
52
+ def all_plaid_ids
53
+ all_plaid_accounts.map { |account| { name: account.name, official_name: account.official_name, plaid_id: account.account_id } }
54
+ end
55
+
56
+ def accounts_descriptions
57
+ accounts.map(&:description)
58
+ end
59
+
60
+ def account(plaid_id:)
61
+ accounts.find { |account| account.plaid_id == plaid_id }
62
+ end
63
+
64
+ # Transactions
65
+
66
+ def fetch_plaid_transactions(from_date: nil, to_date: Date.today)
67
+ start_date = from_date || (to_date - @transactions_horizon)
68
+ plaid_client.transactions.get(plaid_access_token, start_date, to_date).transactions
69
+ end
70
+
71
+ def plaid_transactions
72
+ @plaid_transactions ||= fetch_plaid_transactions(from_date: nil, to_date: Date.today)
73
+ end
74
+
75
+ # Since we fetch all plaid transactions at once and accounts have various
76
+ # started_date, we need to filter line by line
77
+ def importable_plaid_transactions
78
+ @importable_plaid_transactions ||= plaid_transactions.filter do |transaction|
79
+ account = account(plaid_id: transaction.account_id)
80
+ account.start_date.nil? || Date.parse(transaction.date) >= account.start_date
81
+ end
82
+ end
83
+
84
+ def refresh_plaid_transactions!
85
+ plaid_client.transactions.refresh(plaid_access_token)
86
+ end
87
+
88
+ #
89
+ # YNABist
90
+ #
91
+
92
+ def ynab_transactions
93
+ @ynab_transactions ||= importable_plaid_transactions.map do |plaid_transaction|
94
+ account = account(plaid_id: plaid_transaction.account_id)
95
+ converter = ParamsConverter.new account, plaid_transaction
96
+ YNAB::SaveTransaction.new(converter.to_params)
97
+ end
98
+ end
99
+
100
+ def wrapped_ynab_transactions
101
+ YNAB::SaveTransactionsWrapper.new(transactions: ynab_transactions)
102
+ end
103
+
104
+ #
105
+ # Transactions Queries
106
+ #
107
+
108
+ def transactions_for(ynab_id)
109
+ ynab_transactions.select { |transaction| transaction.account_id == ynab_id }
110
+ end
111
+
112
+ def transactions_total(account)
113
+ transactions_for(account.ynab_id).sum(&:amount)
114
+ end
115
+
116
+ def payees
117
+ ynab_transactions.map(&:payee_name).uniq.sort
118
+ end
119
+
120
+ def payees_memos
121
+ ynab_transactions.map { |t| [t.payee_name, t.memo].join(" <~> ") }.uniq.sort
122
+ end
123
+
124
+ #
125
+ # Balances
126
+ #
127
+
128
+ def reconciliate_account(account)
129
+ account.ynab_balance + transactions_total(account) == account.plaid_balance.to_ynab
130
+ end
131
+
132
+ def reconciled?
133
+ @bank.accounts.inject(true) { |reconciled, account| reconciled && reconciliate_account(account) }
134
+ end
135
+
136
+ #
137
+ # Import
138
+ #
139
+
140
+ def import
141
+ @result = ynab_client.transactions.create_transactions(Ynap.config.dig(:ynab, :budget_id), wrapped_ynab_transactions).tap do |result|
142
+ puts "#{name} - New: #{result.data.transaction_ids.size}, Known: #{result.data.duplicate_import_ids.size}\n"
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,9 @@
1
+ class BridgeRecord
2
+ attr_accessor :plaid_client, :plaid_access_token, :ynab_client
3
+
4
+ def initialize(plaid_access_token)
5
+ @plaid_access_token = plaid_access_token
6
+ @plaid_client = Plaid::Client.new Ynap.config[:plaid].slice(:env, :client_id, :secret)
7
+ @ynab_client = YNAB::API.new Ynap.config.dig(:ynab, :token)
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ class PayeeParser
2
+ attr_reader :regex
3
+
4
+ def initialize(regex = nil)
5
+ @regex = regex || Ynap.regexp
6
+ end
7
+
8
+ def cleaned_name(label)
9
+ matched = label.match(@regex)
10
+ matched.nil? ? label : matched[:name].strip
11
+ end
12
+ end
@@ -0,0 +1,52 @@
1
+ class ParamsConverter
2
+ IMPORT_ID_PREFIX = "GW"
3
+ IMPORT_ID_FIXED_SIZE = IMPORT_ID_PREFIX.length + 10 + 3
4
+ MAX_IMPORT_ID_SIZE = 36
5
+
6
+ attr_reader :account, :plaid_transaction
7
+
8
+ def initialize(account, plaid_transaction)
9
+ @account = account
10
+ @plaid_transaction = plaid_transaction
11
+ end
12
+
13
+ def amount
14
+ @amount ||= -(BigDecimal(plaid_transaction.amount.to_s) * 100).to_i
15
+ end
16
+
17
+ def date
18
+ @date ||= Date.parse(plaid_transaction.date)
19
+ end
20
+
21
+ def transaction_id
22
+ @transaction_id ||= plaid_transaction.pending_transaction_id || plaid_transaction.transaction_id
23
+ end
24
+
25
+ def transaction_id_length
26
+ @transaction_id_length ||= MAX_IMPORT_ID_SIZE - IMPORT_ID_FIXED_SIZE - amount.to_s.size
27
+ end
28
+
29
+ def sliced_transaction_id
30
+ @sliced_transaction_id ||= transaction_id[0, transaction_id_length]
31
+ end
32
+
33
+ def import_id
34
+ [IMPORT_ID_PREFIX, amount, date, sliced_transaction_id].join(":")
35
+ end
36
+
37
+ def payee_name
38
+ @payee_name ||= plaid_transaction.merchant_name || PayeeParser.new.cleaned_name(plaid_transaction.name)[0, 50]
39
+ end
40
+
41
+ def to_params
42
+ {
43
+ account_id: account.ynab_id,
44
+ amount: amount * 10,
45
+ cleared: 'cleared',
46
+ date: date,
47
+ import_id: import_id,
48
+ memo: plaid_transaction.name,
49
+ payee_name: payee_name
50
+ }
51
+ end
52
+ end
@@ -0,0 +1,3 @@
1
+ module Ynap
2
+ VERSION = "1.0.0"
3
+ end
metadata ADDED
@@ -0,0 +1,197 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ynap
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Arnaud Joubay
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-11-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: plaid
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '12.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '12.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sinatra
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ynab
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.20'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.20'
55
+ - !ruby/object:Gem::Dependency
56
+ name: awesome_print
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.8'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.8'
69
+ - !ruby/object:Gem::Dependency
70
+ name: byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '11.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '11.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.13'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.13'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '13.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '13.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.9'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: thor
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.0'
139
+ description: YNAP allows you to automatically import into YNAB the transactions of
140
+ any bank supported by Plaid.
141
+ email:
142
+ executables:
143
+ - ynap
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - CHANGELOG.md
148
+ - LICENSE.txt
149
+ - README.md
150
+ - bin/console
151
+ - bin/plaid
152
+ - bin/setup
153
+ - bin/ynap
154
+ - config/ynap.yml.example
155
+ - exe/ynap
156
+ - html/index.html
157
+ - html/oauth-response.html
158
+ - lib/ynap.rb
159
+ - lib/ynap/cli.rb
160
+ - lib/ynap/extensions/float.rb
161
+ - lib/ynap/extensions/integer.rb
162
+ - lib/ynap/extensions/plaid/models/transaction.rb
163
+ - lib/ynap/extensions/ynab/save_transaction.rb
164
+ - lib/ynap/extensions/ynab/transaction_detail.rb
165
+ - lib/ynap/models/account.rb
166
+ - lib/ynap/models/bank.rb
167
+ - lib/ynap/models/bridge_record.rb
168
+ - lib/ynap/payee_parser.rb
169
+ - lib/ynap/values/params_converter.rb
170
+ - lib/ynap/version.rb
171
+ homepage: https://github.com/sowenjub/ynap
172
+ licenses:
173
+ - MIT
174
+ metadata:
175
+ homepage_uri: https://github.com/sowenjub/ynap
176
+ source_code_uri: https://github.com/sowenjub/ynap
177
+ changelog_uri: https://github.com/sowenjub/ynap/CHANGELOG.md
178
+ post_install_message:
179
+ rdoc_options: []
180
+ require_paths:
181
+ - lib
182
+ required_ruby_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: 2.3.0
187
+ required_rubygems_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: '0'
192
+ requirements: []
193
+ rubygems_version: 3.0.3
194
+ signing_key:
195
+ specification_version: 4
196
+ summary: You Need A Plaid
197
+ test_files: []