From 5b6c7bde1d8ea6a4b29ac1803dc65ab9df0b610c Mon Sep 17 00:00:00 2001 From: DJ Gillespie Date: Wed, 27 Sep 2023 21:54:33 -0600 Subject: [PATCH 1/3] added plaid_v2 client, started progress to migrate to LinkTokens from AuthTokens --- connection/connections/plaid_client_v2.py | 115 ++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 connection/connections/plaid_client_v2.py diff --git a/connection/connections/plaid_client_v2.py b/connection/connections/plaid_client_v2.py new file mode 100755 index 0000000..f692c39 --- /dev/null +++ b/connection/connections/plaid_client_v2.py @@ -0,0 +1,115 @@ +from .abstract import AbstractConnectionClient +from django.conf import settings +import os +import datetime + +from plaid.model.link_token_create_request import LinkTokenCreateRequest +from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser +from plaid.model.products import Products +from plaid.model.country_code import CountryCode + + +def format_error(e): + return { + 'error': { + 'display_message': e.display_message, + 'error_code': e.code, + 'error_type': e.type, + 'error_message': e.message}} + + +class Connection(AbstractConnectionClient): + + def __init__(self, credentials): + self.credentials = credentials.dict() + + # Fill in your Plaid API keys - + # https://dashboard.plaid.com/account/keys + self.PLAID_CLIENT_ID = settings.PLAID_CLIENT_ID + self.PLAID_SECRET = settings.PLAID_SECRET + self.PLAID_PUBLIC_KEY = settings.PLAID_PUBLIC_KEY + # Use 'sandbox' to test with Plaid's Sandbox environment (username: user_good, + # password: pass_good) + # Use `development` to test with live users and credentials and `production` + # to go live + self.PLAID_ENV = settings.PLAID_ENV + # PLAID_PRODUCTS is a comma-separated list of products to use when initializing + # Link. Note that this list must contain 'assets' in order for the app to be + # able to create and retrieve asset reports. + self.PLAID_PRODUCTS = settings.PLAID_PRODUCTS + + # PLAID_COUNTRY_CODES is a comma-separated list of countries for which users + # will be able to select institutions from. + self.PLAID_COUNTRY_CODES = settings.PLAID_COUNTRY_CODES + + client_user_id = user.id + # Create a link_token for the given user + request = LinkTokenCreateRequest( + products=[Products("auth")], + client_name="Qrtr Plaid", + country_codes=[CountryCode('US')], + redirect_uri='https://domainname.com/oauth-page.html', + language='en', + webhook='https://webhook.example.com', + user=LinkTokenCreateRequestUser( + client_user_id=client_user_id + ) + ) + response = client.link_token_create(request) + + self.credentials.update(response.to_dict()) + return self.credentials + + def get_auth_token(self, public_token): + try: + exchange_request = ItemPublicTokenExchangeRequest( + public_token=public_token + ) + exchange_response = self.client.item_public_token_exchange( + exchange_request) + except Exception as e: + print("Error Occurred") + print(e) + return format_error(e) + access_token = exchange_response['access_token'] + item_id = exchange_response['item_id'] + return {"access_token":access_token, "item_id":item_id} + + def get_accounts(self, auth_token=None): + if not auth_token: + auth_token = self.credentials.get('auth_token') + if not auth_token: + raise Exception("Missing Auth Token") + try: + acc_request = AccountsGetRequest(access_token=auth_token) + accounts = self.client.accounts_get(acc_request).to_dict() + except Exception as e: + print(e) + accounts = None + return accounts + + def get_transactions( + self, + start_date=None, + end_date=None, + auth_token=None): + if not auth_token: + auth_token = self.credentials.get('auth_token') + if not auth_token: + raise Exception("Missing Auth Token") + if not start_date: + start_date = '{:%Y-%m-%d}'.format( + datetime.datetime.now() + datetime.timedelta(-30)) + if not end_date: + end_date = '{:%Y-%m-%d}'.format(datetime.datetime.now()) + try: + transactions_req = TransactionsGetRequest( + access_token=auth_token, + start_date=start_date, + end_date=end_date + ) + transactions_resp = self.client.transactions_get( + transactions_req) + except plaid.errors.PlaidError as e: + return format_error(e) + return transactions_resp.get("transactions") From e942f1a76e7211293ccf33729486a1565eabfbd1 Mon Sep 17 00:00:00 2001 From: DJ Gillespie Date: Wed, 6 Dec 2023 21:04:07 -0700 Subject: [PATCH 2/3] finished up new link flow updates. Still need to implement secondary get_auth_token flow. --- connection/connections/plaid_client_v2.py | 21 ++++++++++++++++----- connection/views.py | 16 ++++++++-------- requirements.txt | 4 ++-- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/connection/connections/plaid_client_v2.py b/connection/connections/plaid_client_v2.py index f692c39..1a037d7 100755 --- a/connection/connections/plaid_client_v2.py +++ b/connection/connections/plaid_client_v2.py @@ -3,6 +3,8 @@ from django.conf import settings import os import datetime +import plaid +from plaid.api import plaid_api from plaid.model.link_token_create_request import LinkTokenCreateRequest from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser from plaid.model.products import Products @@ -20,7 +22,7 @@ def format_error(e): class Connection(AbstractConnectionClient): - def __init__(self, credentials): + def __init__(self, credentials, account_id=None): self.credentials = credentials.dict() # Fill in your Plaid API keys - @@ -28,7 +30,7 @@ class Connection(AbstractConnectionClient): self.PLAID_CLIENT_ID = settings.PLAID_CLIENT_ID self.PLAID_SECRET = settings.PLAID_SECRET self.PLAID_PUBLIC_KEY = settings.PLAID_PUBLIC_KEY - # Use 'sandbox' to test with Plaid's Sandbox environment (username: user_good, + # Use 'sandbox' to test with Plaid's Sandbox environment (usplaid-python==9.2.0ername: user_good, # password: pass_good) # Use `development` to test with live users and credentials and `production` # to go live @@ -42,17 +44,26 @@ class Connection(AbstractConnectionClient): # will be able to select institutions from. self.PLAID_COUNTRY_CODES = settings.PLAID_COUNTRY_CODES - client_user_id = user.id + configuration = plaid.Configuration( + host=self.PLAID_ENV, + api_key={ + 'clientId': self.PLAID_CLIENT_ID, + 'secret': self.PLAID_SECRET, + } + ) + api_client = plaid.ApiClient(configuration) + client = plaid_api.PlaidApi(api_client) + # Create a link_token for the given user request = LinkTokenCreateRequest( products=[Products("auth")], client_name="Qrtr Plaid", country_codes=[CountryCode('US')], - redirect_uri='https://domainname.com/oauth-page.html', + #redirect_uri='https://domainname.com/oauth-page.html', language='en', webhook='https://webhook.example.com', user=LinkTokenCreateRequestUser( - client_user_id=client_user_id + client_user_id=account_id ) ) response = client.link_token_create(request) diff --git a/connection/views.py b/connection/views.py index 147e067..83a19d0 100644 --- a/connection/views.py +++ b/connection/views.py @@ -37,14 +37,14 @@ class ConnectionViewSet(viewsets.ModelViewSet): def authenticate(self, request): print(request.data) print(request.data.keys()) - public_token = request.data.get("public_token") + # public_token = request.data.get("public_token") name = request.data.get("name", "dummyName") account_id = request.data.get("account") print(f"Account ID Detected: {account_id}") - if public_token is None: - return Response( - status=status.HTTP_400_BAD_REQUEST, - data="ERROR: missing public_token") + # if public_token is None: + # return Response( + # status=status.HTTP_400_BAD_REQUEST, + # data="ERROR: missing public_token") if account_id is None: return Response( status=status.HTTP_400_BAD_REQUEST, @@ -63,10 +63,10 @@ class ConnectionViewSet(viewsets.ModelViewSet): print(f"Account Found: {accounts[0]}") account = accounts[0] print(request) - plaid_conn = importlib.import_module(f"connection.connections.plaid_client") + plaid_conn = importlib.import_module(f"connection.connections.plaid_client_v2") conn_type = ConnectionType.objects.get(name="Plaid") try: - plaid_client = plaid_conn.Connection(request.data) + plaid_client = plaid_conn.Connection(request.data, account_id=account_id) except ValueError: return Response(status=status.HTTP_503, data="ERROR: Invalid public_token") @@ -83,7 +83,7 @@ class ConnectionViewSet(viewsets.ModelViewSet): }) conn.credentials = plaid_client.credentials conn.save() - return Response(plaid_client.get_accounts()) + return Response(plaid_client.credentials) @action(detail=False, methods=['post'], url_path='plaid-webhook', permission_classes=[AllowAny]) diff --git a/requirements.txt b/requirements.txt index a438490..a7fea57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ djangorestframework-simplejwt==4.6.0 drf-yasg==1.20.0 idna==2.10 oauthlib==3.1.0 -plaid-python==9.2.0 -psycopg2==2.8.6 +plaid_python==14.0.0 +psycopg2-binary==2.8.6 pycparser==2.20 PyJWT==2.1.0 python3-openid==3.2.0 From cca13d4a4f1df589dfe8d929b1449e1e185255ff Mon Sep 17 00:00:00 2001 From: DJ Gillespie Date: Sun, 3 Mar 2024 00:05:20 -0700 Subject: [PATCH 3/3] finalize plaid v2 migration --- connection/connections/plaid_client_v2.py | 76 +++++++++++++++-------- connection/views.py | 46 +++++++++++++- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/connection/connections/plaid_client_v2.py b/connection/connections/plaid_client_v2.py index 1a037d7..fd92c75 100755 --- a/connection/connections/plaid_client_v2.py +++ b/connection/connections/plaid_client_v2.py @@ -7,6 +7,9 @@ import plaid from plaid.api import plaid_api from plaid.model.link_token_create_request import LinkTokenCreateRequest from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser +from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest +from plaid.model.transactions_sync_request import TransactionsSyncRequest +from plaid.model.accounts_get_request import AccountsGetRequest from plaid.model.products import Products from plaid.model.country_code import CountryCode @@ -23,7 +26,8 @@ def format_error(e): class Connection(AbstractConnectionClient): def __init__(self, credentials, account_id=None): - self.credentials = credentials.dict() + self.credentials = credentials + self.account_id = account_id # Fill in your Plaid API keys - # https://dashboard.plaid.com/account/keys @@ -51,9 +55,29 @@ class Connection(AbstractConnectionClient): 'secret': self.PLAID_SECRET, } ) - api_client = plaid.ApiClient(configuration) - client = plaid_api.PlaidApi(api_client) + self.api_client = plaid.ApiClient(configuration) + self.client = plaid_api.PlaidApi(self.api_client) + # # Create a link_token for the given user + # request = LinkTokenCreateRequest( + # products=[Products("auth")], + # client_name="Qrtr Plaid", + # country_codes=[CountryCode('US')], + # #redirect_uri='https://domainname.com/oauth-page.html', + # language='en', + # webhook='https://webhook.example.com', + # user=LinkTokenCreateRequestUser( + # client_user_id=self.account_id + # ) + # ) + # response = client.link_token_create(request) + # resp_dict = response.to_dict() + # resp_dict['expiration'] = resp_dict['expiration'].strftime('%s') + + # self.credentials.update(resp_dict) + # return self.credentials + + def generate_auth_request(self): # Create a link_token for the given user request = LinkTokenCreateRequest( products=[Products("auth")], @@ -63,13 +87,14 @@ class Connection(AbstractConnectionClient): language='en', webhook='https://webhook.example.com', user=LinkTokenCreateRequestUser( - client_user_id=account_id + client_user_id=self.account_id ) ) - response = client.link_token_create(request) + response = self.client.link_token_create(request) + resp_dict = response.to_dict() + resp_dict['expiration'] = resp_dict['expiration'].strftime('%s') - self.credentials.update(response.to_dict()) - return self.credentials + self.credentials.update(resp_dict) def get_auth_token(self, public_token): try: @@ -84,11 +109,12 @@ class Connection(AbstractConnectionClient): return format_error(e) access_token = exchange_response['access_token'] item_id = exchange_response['item_id'] + self.credentials.update({"access_token":access_token, "item_id":item_id}) return {"access_token":access_token, "item_id":item_id} def get_accounts(self, auth_token=None): if not auth_token: - auth_token = self.credentials.get('auth_token') + auth_token = self.credentials.get('access_token') if not auth_token: raise Exception("Missing Auth Token") try: @@ -97,7 +123,7 @@ class Connection(AbstractConnectionClient): except Exception as e: print(e) accounts = None - return accounts + return accounts.get('accounts') def get_transactions( self, @@ -105,22 +131,20 @@ class Connection(AbstractConnectionClient): end_date=None, auth_token=None): if not auth_token: - auth_token = self.credentials.get('auth_token') - if not auth_token: - raise Exception("Missing Auth Token") - if not start_date: - start_date = '{:%Y-%m-%d}'.format( - datetime.datetime.now() + datetime.timedelta(-30)) - if not end_date: - end_date = '{:%Y-%m-%d}'.format(datetime.datetime.now()) - try: - transactions_req = TransactionsGetRequest( + auth_token = self.credentials.get('access_token') + request = TransactionsSyncRequest( + access_token=auth_token, + ) + response = self.client.transactions_sync(request) + transactions = response['added'] + + # the transactions in the response are paginated, so make multiple calls while incrementing the cursor to + # retrieve all transactions + while (response['has_more']): + request = TransactionsSyncRequest( access_token=auth_token, - start_date=start_date, - end_date=end_date + cursor=response['next_cursor'] ) - transactions_resp = self.client.transactions_get( - transactions_req) - except plaid.errors.PlaidError as e: - return format_error(e) - return transactions_resp.get("transactions") + response = self.client.transactions_sync(request) + transactions += response['added'] + return transactions diff --git a/connection/views.py b/connection/views.py index 83a19d0..ec3c78d 100644 --- a/connection/views.py +++ b/connection/views.py @@ -33,6 +33,48 @@ class ConnectionViewSet(viewsets.ModelViewSet): 'delete', 'options'] + + @action(detail=False, methods=['post'], url_path='plaid/exchange_public_token') + def exchange_public_token(self, request): + print(f"REQUEST: {request.data}") + name = request.data.get("name", "dummyName") + account_id = request.data.get("account") + public_token = request.data.get("public_token") + user = request.user + accounts = (Account.objects.filter(pk=account_id, owner=user) | + Account.objects.filter(pk=account_id, + admin_users__in=[user])) + if not accounts: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data="ERROR: Account ID not found") + else: + print(f"Account Found: {accounts[0]}") + account = accounts[0] + print(request) + plaid_conn = importlib.import_module(f"connection.connections.plaid_client_v2") + conn_type = ConnectionType.objects.get(name="Plaid") + try: + plaid_client = plaid_conn.Connection(request.data.dict(), account_id=account_id) + token = plaid_client.get_auth_token(public_token) + except ValueError: + return Response(status=status.HTTP_503, + data="ERROR: Invalid public_token") + with transaction.atomic(): + conn, created = Connection.objects \ + .get_or_create(name=name, type=conn_type, + defaults={ + "credentials": request.data, + "account": account + }) + conn.credentials = plaid_client.credentials + print(f"CREDS: {plaid_client.credentials}") + conn.save() + return Response(plaid_client.get_accounts()) + + + + @action(detail=False, methods=['post'], url_path='plaid') def authenticate(self, request): print(request.data) @@ -66,7 +108,8 @@ class ConnectionViewSet(viewsets.ModelViewSet): plaid_conn = importlib.import_module(f"connection.connections.plaid_client_v2") conn_type = ConnectionType.objects.get(name="Plaid") try: - plaid_client = plaid_conn.Connection(request.data, account_id=account_id) + plaid_client = plaid_conn.Connection(request.data.dict(), account_id=account_id) + plaid_client.generate_auth_request() except ValueError: return Response(status=status.HTTP_503, data="ERROR: Invalid public_token") @@ -82,6 +125,7 @@ class ConnectionViewSet(viewsets.ModelViewSet): "account": account }) conn.credentials = plaid_client.credentials + print(f"CREDS: {plaid_client.credentials}") conn.save() return Response(plaid_client.credentials)