from django import forms from django.http import HttpResponseRedirect from django.shortcuts import render from django.conf import settings import io import json from postgresqleu.util.widgets import StaticTextWidget, MonospaceTextarea from postgresqleu.util.forms import SubmitButtonField from postgresqleu.util.payment.banktransfer import BaseManagedBankPayment from postgresqleu.util.payment.banktransfer import BaseManagedBankPaymentForm from postgresqleu.mailqueue.util import send_simple_mail import requests class BackendPlaidForm(BaseManagedBankPaymentForm): description = forms.CharField(required=True, widget=MonospaceTextarea, help_text='Text shown on page promting the user to select payment') clientid = forms.CharField(label='Client ID', required=True) secret = forms.CharField(required=True, widget=forms.widgets.PasswordInput(render_value=True)) notification_receiver = forms.EmailField(required=True) notify_each_transaction = forms.BooleanField(required=False, help_text="Send an email notification for each transaction received") verify_balances = forms.BooleanField(required=False, help_text="Regularly verify that the account balance matches the accounting system") connect = SubmitButtonField(label="Connect to plaid", required=False) connection = forms.CharField(label='Connection', required=False, widget=StaticTextWidget) reconnect = SubmitButtonField(label="Refresh connection to plaid", required=False) config_readonly = ['connect', 'connection', 'reconnect', ] managed_fields = ['description', 'clientid', 'secret', 'connect', 'connection', 'reconnect', 'notification_receiver', 'notify_each_transaction', 'verify_balances', ] managed_fieldsets = [ { 'id': 'plaid', 'legend': 'Plaid', 'fields': ['clientid', 'secret', ], }, { 'id': 'notifications', 'legend': 'Notifications', 'fields': ['notification_receiver', 'notify_each_transaction', 'verify_balances', ], }, { 'id': 'connection', 'legend': 'Connection', 'fields': ['connect', 'connection', 'reconnect', ], }, ] @property def config_fieldsets(self): f = super().config_fieldsets for ff in f: if ff['id'] == 'invoice': ff['fields'].append('description') return f def fix_fields(self): super().fix_fields() self.fields['feeaccount'].help_text = 'Currently no fees are fetched, so this account is a no-op' if 'accountid' in self.instance.config: self.initial['connection'] = 'Connected to plaid account {}.'.format(self.instance.config['accountid']) self.fields['connect'].widget.label = "Already connected" self.fields['connect'].widget.attrs['disabled'] = True self.fields['reconnect'].callback = self.refresh_plaid_connect else: self.fields['connect'].callback = self.connect_to_plaid self.initial['connection'] = 'Not connected.' self.fields['reconnect'].widget.label = 'Not connected' self.fields['reconnect'].widget.attrs['disabled'] = True if not self.instance.config.get('clientid', None) or not self.instance.config.get('secret', None): self.fields['connect'].widget.attrs['disabled'] = True self.fields['connect'].help_text = "Save the client and secret id before you can connect to plaid" def connect_to_plaid(self, request): return HttpResponseRedirect("plaidconnect/") def refresh_plaid_connect(self, request): return HttpResponseRedirect("refreshplaidconnect/") class Plaid(BaseManagedBankPayment): backend_form_class = BackendPlaidForm ROOTURL = 'https://{}.plaid.com/'.format(settings.PLAID_LEVEL) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session = requests.sessions.Session() self.session.headers.update({ 'PLAID-CLIENT-ID': self.method.config.get('clientid', ''), 'PLAID-SECRET': self.method.config.get('secret', ''), }) @property def description(self): return self.config('description').replace("\n", '
') if self.config('description') else '' def render_page(self, request, invoice): return render(request, 'invoices/genericbankpayment.html', { 'invoice': invoice, 'bankinfo': self.config('bankinfo'), }) def get_link_token(self, previous_accesstoken=None): payload = { 'client_name': settings.ORG_NAME, 'language': 'en', 'country_codes': settings.PLAID_COUNTRIES, 'user': { 'client_user_id': str(self.method.id), }, 'products': ['transactions', ], 'webhook': '{}/wh/plaid/{}/'.format(settings.SITEBASE, self.method.id), } if previous_accesstoken: payload['access_token'] = previous_accesstoken r = self.session.post('{}link/token/create'.format(self.ROOTURL), json=payload, timeout=10) if r.status_code != 200: return None return r.json()['link_token'] def exchange_token(self, public_token): r = self.session.post('{}item/public_token/exchange'.format(self.ROOTURL), json={ 'public_token': public_token, }, timeout=10) if r.status_code != 200: return None return r.json()['access_token'] def disconnect(self): self.session.post('{}/item/remove'.format(self.ROOTURL), json={ 'access_token': self.method.config.get('access_token', ''), }, timeout=10) def get_account_balances(self): r = self.session.post('{}auth/get'.format(self.ROOTURL), json={ 'access_token': self.method.config.get('access_token', ''), }, timeout=10) if r.status_code != 200: return [] return [ { 'accountid': a['account_id'], 'balance': a['balances']['current'], 'currency': a['balances']['iso_currency_code'], } for a in r.json()['accounts'] ] def get_signing_key(self, current_key_id): cache = self.method.config.get('signing_key_cache', {}) if current_key_id in cache: return cache[current_key_id] # Refresh our cache of keys keys_ids_to_update = [key_id for key_id, key in cache.items() if key['expired_at'] is None] keys_ids_to_update.append(current_key_id) newcache = {} for key_id in keys_ids_to_update: r = self.session.post('{}webhook_verification_key/get'.format(self.ROOTURL), json={ 'key_id': key_id }, timeout=8) if r.status_code != 200: continue newcache[key_id] = r.json()['key'] self.method.config['signing_key_cache'] = newcache self.method.save(update_fields=['config', ]) return newcache.get(current_key_id, None) def sync_transactions(self): # Sync transactions from plaid. # ONLY added transactions supported at this point. Anything under removed or changed will be turned # into an email notification only. if 'access_token' not in self.method.config: print("No access token, exiting") return [] notes = io.StringIO() initial_cursor = self.method.config.get('sync_cursor', None) param = { 'access_token': self.method.config['access_token'], 'count': 100, } transactions = [] while True: if 'sync_cursor' in self.method.config: param['cursor'] = self.method.config['sync_cursor'] r = self.session.post('{}transactions/sync'.format(self.ROOTURL), json=param) if r.status_code == 400: j = r.json() if j['error_code'] == 'ITEM_LOGIN_REQUIRED': raise Exception("Login refresh needed for plaid account: {}".format(j['error_message'])) r.raise_for_status() j = r.json() for t in j['added']: if t['iso_currency_code'] != settings.CURRENCY_ISO: notes.write("Transaction {}, description '{}', has invalid currency {}.\n".format(t['transaction_id'], t['name'], t['iso_currency_code'])) if t['account_id'] != self.method.config['accountid']: notes.write("Transaction {}, description '{}', is on account {}, but we only know about {}.\n".format( t['transaction_id'], t['name'], t['account_id'], self.method.config['accountid'], )) for t in j['modified']: notes.write("Transaction modification entry for {}, can't process.\n".format(t['transaction_id'])) notes.write("{}\n----\n".format(json.dumps(t))) for t in j['removed']: notes.write("Transaction {} removed, can't process.\n".format(t['transaction_id'])) notes.write("{}\n----\n".format(json.dumps(t))) transactions.extend([t for t in j['added'] if t['iso_currency_code'] == settings.CURRENCY_ISO and t['account_id'] == self.method.config['accountid']]) self.method.config['sync_cursor'] = j['next_cursor'] if not j.get('has_more', False): break # if we have more, we loop up to get more if initial_cursor != self.method.config['sync_cursor']: self.method.save(update_fields=['config']) if notes.tell(): # Some notes were generated. We don't have a good way to handle this, so we're just going to generate an email with it... send_simple_mail( settings.INVOICE_SENDER_EMAIL, self.method.config['notification_receiver'], "Plaid transaction fetch notices for {}".format(self.method.internaldescription), "Fetching plaid transactions for {} resulted in some noties:\n\n{}\n".format( self.method.internaldescription, notes.getvalue(), ), ) return transactions