ynap 1.0.0

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.
@@ -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: []