-
Notifications
You must be signed in to change notification settings - Fork 6
/
plaidapi.py
199 lines (157 loc) · 6.68 KB
/
plaidapi.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
#!/python3
import re
import datetime
import plaid
from typing import Optional, List
class AccountBalance:
def __init__(self, data):
self.raw_data = data
self.account_id = data['account_id']
self.account_name = data['name']
self.account_type = data['type']
self.account_subtype = data['subtype']
self.account_number = data['mask']
self.balance_current = data['balances']['current']
self.balance_available = data['balances']['available']
self.balance_limit = data['balances']['limit']
self.currency_code = data['balances']['iso_currency_code']
class AccountInfo:
def __init__(self, data):
self.raw_data = data
self.item_id = data['item']['item_id']
self.institution_id = data['item']['institution_id']
self.ts_consent_expiration = parse_optional_iso8601_timestamp(data['item']['consent_expiration_time'])
self.ts_last_failed_update = parse_optional_iso8601_timestamp(data['status']['transactions']['last_failed_update'])
self.ts_last_successful_update = parse_optional_iso8601_timestamp(data['status']['transactions']['last_successful_update'])
class Transaction:
def __init__(self, data):
self.raw_data = data
self.account_id = data['account_id']
self.date = data['date']
self.transaction_id = data['transaction_id']
self.pending = data['pending']
self.merchant_name = data['merchant_name']
self.amount = data['amount']
self.currency_code = data['iso_currency_code']
def __str__(self):
return "%s %s %s - %4.2f %s" % ( self.date, self.transaction_id, self.merchant_name, self.amount, self.currency_code )
def parse_optional_iso8601_timestamp(ts: Optional[str]) -> datetime.datetime:
if ts is None:
return None
# sometimes the milliseconds coming back from plaid have less than 3 digits
# which fromisoformat hates - it also hates "Z", so strip those off from this
# string (the milliseconds hardly matter for this purpose, and I'd rather avoid
# having to pull dateutil JUST for this parsing)
return datetime.datetime.fromisoformat(re.sub(r"[.][0-9]+Z", "+00:00", ts))
def raise_plaid(ex: plaid.errors.ItemError):
if ex.code == 'NO_ACCOUNTS':
raise PlaidNoApplicableAccounts(ex)
elif ex.code == 'ITEM_LOGIN_REQUIRED':
raise PlaidAccountUpdateNeeded(ex)
else:
raise PlaidUnknownError(ex)
def wrap_plaid_error(f):
def wrap(*args, **kwargs):
try:
return f(*args, **kwargs)
except plaid.errors.PlaidError as ex:
raise_plaid(ex)
return wrap
class PlaidError(Exception):
def __init__(self, plaid_error):
super().__init__()
self.plaid_error = plaid_error
self.message = plaid_error.message
def __str__(self):
return "%s: %s" % (self.plaid_error.code, self.message)
class PlaidUnknownError(PlaidError):
pass
class PlaidNoApplicableAccounts(PlaidError):
pass
class PlaidAccountUpdateNeeded(PlaidError):
pass
class PlaidAPI():
def __init__(self, client_id: str, secret: str, environment: str, suppress_warnings=True):
self.client = plaid.Client(
client_id,
secret,
environment,
suppress_warnings
)
@wrap_plaid_error
def get_link_token(self, access_token=None) -> str:
"""
Calls the /link/token/create workflow, which returns an access token
which can be used to initate the account linking process or, if an access_token
is provided, to update an existing linked account.
This token is used by the web-browser/JavaScript API to exchange for a public
token to finalize the linking process.
https://plaid.com/docs/api/tokens/#token-exchange-flow
"""
data = {
'user': {
'client_user_id': 'abc123',
},
'client_name': 'plaid-sync',
'country_codes': ['US'],
'language': 'en',
}
# if updating an existing account, the products field is not allowed
if access_token:
data['access_token'] = access_token
else:
data['products'] = ['transactions']
return self.client.post('/link/token/create', data)['link_token']
@wrap_plaid_error
def exchange_public_token(self, public_token: str) -> str:
"""
Exchange a temporary public token for a permanent private
access token.
"""
return self.client.Item.public_token.exchange(public_token)
@wrap_plaid_error
def sandbox_reset_login(self, access_token: str) -> str:
"""
Only applicable to sandbox environment. Resets the login
details for a specific account so you can test the update
account flow.
Otherwise, attempting to update will just display "Account
already connected." in the Plaid browser UI.
"""
return self.client.post('/sandbox/item/reset_login', {
'access_token': access_token,
})
@wrap_plaid_error
def get_item_info(self, access_token: str)->AccountInfo:
"""
Returns account information associated with this particular access token.
"""
resp = self.client.Item.get(access_token)
return AccountInfo(resp)
@wrap_plaid_error
def get_account_balance(self, access_token:str)->List[AccountBalance]:
"""
Returns the balances of all accounts associated with this particular access_token.
"""
resp = self.client.Accounts.balance.get(access_token=access_token)
return list( map( AccountBalance, resp['accounts'] ) )
@wrap_plaid_error
def get_transactions(self, access_token:str, start_date:datetime.date, end_date:datetime.date, account_ids:Optional[List[str]]=None, status_callback=None):
ret = []
total_transactions = None
while True:
response = self.client.Transactions.get(
access_token,
start_date.strftime("%Y-%m-%d"),
end_date.strftime("%Y-%m-%d"),
account_ids=account_ids,
offset=len(ret),
count=500)
total_transactions = response['total_transactions']
ret += [
Transaction(t)
for t in response['transactions']
]
if status_callback: status_callback(len(ret), total_transactions)
if len(ret) >= total_transactions: break
return ret