diff options
Diffstat (limited to 'postgresqleu')
-rw-r--r-- | postgresqleu/plaid/admin.py | 10 | ||||
-rw-r--r-- | postgresqleu/plaid/backendviews.py | 57 | ||||
-rw-r--r-- | postgresqleu/plaid/management/__init__.py | 0 | ||||
-rw-r--r-- | postgresqleu/plaid/management/commands/__init__.py | 0 | ||||
-rw-r--r-- | postgresqleu/plaid/management/commands/plaid_fetch_transactions.py | 79 | ||||
-rw-r--r-- | postgresqleu/plaid/migrations/0001_initial.py | 47 | ||||
-rw-r--r-- | postgresqleu/plaid/migrations/__init__.py | 0 | ||||
-rw-r--r-- | postgresqleu/plaid/models.py | 32 | ||||
-rw-r--r-- | postgresqleu/plaid/views.py | 106 | ||||
-rw-r--r-- | postgresqleu/settings.py | 7 | ||||
-rw-r--r-- | postgresqleu/urls.py | 6 | ||||
-rw-r--r-- | postgresqleu/util/payment/__init__.py | 1 | ||||
-rw-r--r-- | postgresqleu/util/payment/plaid.py | 217 |
13 files changed, 562 insertions, 0 deletions
diff --git a/postgresqleu/plaid/admin.py b/postgresqleu/plaid/admin.py new file mode 100644 index 00000000..f1658e70 --- /dev/null +++ b/postgresqleu/plaid/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import PlaidTransaction + + +class PlaidTransactionAdmin(admin.ModelAdmin): + list_display = ('transactionid', 'datetime', 'amount', 'paymentref') + + +admin.site.register(PlaidTransaction, PlaidTransactionAdmin) diff --git a/postgresqleu/plaid/backendviews.py b/postgresqleu/plaid/backendviews.py new file mode 100644 index 00000000..7249a9fe --- /dev/null +++ b/postgresqleu/plaid/backendviews.py @@ -0,0 +1,57 @@ +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.contrib import messages +from django.conf import settings + +from postgresqleu.util.auth import authenticate_backend_group +from postgresqleu.invoices.models import InvoicePaymentMethod + + +def _do_balance_check(request, paymentmethod, impl): + balances = impl.get_account_balances() + if len(balances) == 0: + # 0 accounts can mean we just haven't updated yet, so set up a loop + return render(request, 'plaid/check_account.html', { + }) + if len(balances) != 1: + messages.error(request, 'Returned {} accounts, should be 1, cannot use this connection.'.format(len(balances))) + impl.disconnect() + return HttpResponseRedirect("../") + elif balances[0]['currency'] != settings.CURRENCY_ISO: + messages.error(request, 'Currency on account {} is {}, expected {}, cannot use this connection.'.format(balances[0]['accountid'], balances[0]['currency'], settings.CURRENCY_ISO)) + impl.disconnect() + return HttpResponseRedirect("../") + else: + messages.info(request, "Account {} connected.".format(balances[0]['accountid'])) + paymentmethod.config['accountid'] = balances[0]['accountid'] + paymentmethod.save(update_fields=['config', ]) + return HttpResponseRedirect('../') + + +def connect_to_plaid(request, paymentmethodid): + authenticate_backend_group(request, 'Invoice managers') + + paymentmethod = get_object_or_404(InvoicePaymentMethod, pk=paymentmethodid, classname='postgresqleu.util.payment.plaid.Plaid') + + impl = paymentmethod.get_implementation() + + if request.method == 'GET' and request.GET.get('check_account', '0') == '1': + # We're in the check account loop + return _do_balance_check(request, paymentmethod, impl) + + if request.method == 'POST': + paymentmethod.config['access_token'] = impl.exchange_token(request.POST['public_token']) + if not paymentmethod.config['access_token']: + messages.error(request, 'Could not exchange public token for permanent token.') + return HttpResponseRedirect('../') + + return _do_balance_check(request, paymentmethod, impl) + + token = impl.get_link_token() + if not token: + messages.error(request, "Could not create link token") + return HttpResponseRedirect("../") + + return render(request, 'plaid/connectaccount.html', { + 'token': token, + }) diff --git a/postgresqleu/plaid/management/__init__.py b/postgresqleu/plaid/management/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/plaid/management/__init__.py diff --git a/postgresqleu/plaid/management/commands/__init__.py b/postgresqleu/plaid/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/plaid/management/commands/__init__.py diff --git a/postgresqleu/plaid/management/commands/plaid_fetch_transactions.py b/postgresqleu/plaid/management/commands/plaid_fetch_transactions.py new file mode 100644 index 00000000..4206642a --- /dev/null +++ b/postgresqleu/plaid/management/commands/plaid_fetch_transactions.py @@ -0,0 +1,79 @@ +# Fetch transaction list from plaid +# +# Copyright (C) 2023, PostgreSQL Europe +# + +from django.core.management.base import BaseCommand +from django.utils.dateparse import parse_date, parse_datetime +from django.utils.timezone import make_aware +from django.db import transaction +from django.conf import settings + +from postgresqleu.invoices.util import register_bank_transaction +from postgresqleu.invoices.models import InvoicePaymentMethod +from postgresqleu.mailqueue.util import send_simple_mail +from postgresqleu.plaid.models import PlaidTransaction + +from datetime import timedelta, datetime, time + + +class Command(BaseCommand): + help = 'Fetch Plaid transactions' + + class ScheduledJob: + scheduled_interval = timedelta(hours=12) + + @classmethod + def should_run(self): + return InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.plaid.Plaid').exists() + + def add_arguments(self, parser): + parser.add_argument('--no-banktransactions', action='store_true', help="Don't create banktransaction entries for found records (useful for initial load)") + + @transaction.atomic + def handle(self, *args, **options): + self.do_banktransactions = not options['no_banktransactions'] + + for method in InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.plaid.Plaid'): + self.handle_method(method) + + def handle_method(self, method): + impl = method.get_implementation() + + for t in impl.sync_transactions(): + # a sync_transactions should normally only get transactions to add, but there is at least a small chance + # that we can get the same one again, so we dupe-check it. + trans, created = PlaidTransaction.objects.get_or_create( + paymentmethod=method, + transactionid=t['transaction_id'], + defaults={ + 'datetime': parse_datetime(t['datetime']) if t['datetime'] else make_aware(datetime.combine(parse_date(t['date']), time(0, 0))), + 'amount': -t['amount'], # All plaid amounts are reported negative + 'paymentref': t['name'][:200], + 'transactionobject': t, + } + ) + if created: + if method.config.get('notify_each_transaction', False): + send_simple_mail( + settings.INVOICE_SENDER_EMAIL, + method.config['notification_receiver'], + "Plaid transaction received on {}".format(method.internaldescription), + "A new plaid transaction has been registered for {}:\n\nDate: {}\nAmonut: {}\nText: {}\n".format( + method.internaldescription, + trans.datetime, + trans.amount, + trans.paymentref, + ), + ) + + # Else register a pending bank transaction. This may immediately match an invoice + # if it was an invoice payment, in which case the entire process will complete.. + if self.do_banktransactions: + register_bank_transaction( + method, + trans.id, + trans.amount, + trans.paymentref, + trans.paymentref, + ) diff --git a/postgresqleu/plaid/migrations/0001_initial.py b/postgresqleu/plaid/migrations/0001_initial.py new file mode 100644 index 00000000..e03bdffe --- /dev/null +++ b/postgresqleu/plaid/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.14 on 2023-06-13 09:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('invoices', '0018_longer_invoice_history'), + ] + + operations = [ + migrations.CreateModel( + name='PlaidTransaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transactionid', models.CharField(max_length=100)), + ('datetime', models.DateTimeField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('paymentref', models.CharField(blank=True, max_length=200)), + ('transactionobject', models.JSONField(default=dict)), + ('paymentmethod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.invoicepaymentmethod')), + ], + options={ + 'ordering': ('-datetime',), + 'unique_together': {('transactionid', 'paymentmethod')}, + }, + ), + + migrations.CreateModel( + name='PlaidWebhookData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(auto_now_add=True, db_index=True)), + ('source', models.GenericIPAddressField(null=True, blank=True)), + ('signature', models.CharField(max_length=1000)), + ('hook_code', models.CharField(max_length=200)), + ('contents', models.JSONField(default=dict)), + ], + options={ + 'ordering': ('-datetime',), + }, + ), + ] diff --git a/postgresqleu/plaid/migrations/__init__.py b/postgresqleu/plaid/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/plaid/migrations/__init__.py diff --git a/postgresqleu/plaid/models.py b/postgresqleu/plaid/models.py new file mode 100644 index 00000000..01b2fbb8 --- /dev/null +++ b/postgresqleu/plaid/models.py @@ -0,0 +1,32 @@ +from django.db import models + +from postgresqleu.invoices.models import InvoicePaymentMethod + + +class PlaidTransaction(models.Model): + paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False, on_delete=models.CASCADE) + transactionid = models.CharField(max_length=100, blank=False, null=False) + datetime = models.DateTimeField(null=False, blank=False) + amount = models.DecimalField(decimal_places=2, max_digits=20, null=False) + paymentref = models.CharField(max_length=200, blank=True, null=False) + transactionobject = models.JSONField(null=False, blank=False, default=dict) + + class Meta: + unique_together = ( + ('transactionid', 'paymentmethod'), + ) + ordering = ('-datetime', ) + + def __str__(self): + return self.transactionid + + +class PlaidWebhookData(models.Model): + datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, db_index=True) + source = models.GenericIPAddressField(null=True, blank=True) + signature = models.CharField(max_length=1000, null=False, blank=False) + hook_code = models.CharField(max_length=200, null=False, blank=False) + contents = models.JSONField(null=False, blank=False, default=dict) + + class Meta: + ordering = ('-datetime', ) diff --git a/postgresqleu/plaid/views.py b/postgresqleu/plaid/views.py new file mode 100644 index 00000000..aa16766e --- /dev/null +++ b/postgresqleu/plaid/views.py @@ -0,0 +1,106 @@ +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt + +from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1 +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers +import hashlib +import hmac +import json +import jwt +import time + +from postgresqleu.invoices.models import InvoicePaymentMethod +from postgresqleu.scheduler.util import trigger_immediate_job_run +from postgresqleu.plaid.models import PlaidWebhookData + + +def _validate_signature(request, method): + signed_jwt = request.META.get('HTTP_PLAID_VERIFICATION', '') + current_key_id = jwt.get_unverified_header(signed_jwt)['kid'] + + impl = method.get_implementation() + key = impl.get_signing_key(current_key_id) + if not key: + print("Signing key {} not found".format(current_key_id)) + return False + + if key.get('expired_at', None) is not None: + print("Key expired") + return False + + if key['kty'] != 'EC' or key['alg'] != 'ES256' or key['crv'] != 'P-256': + print("Unknown type of key") + return False + + # This is included in newest versions of pyjwt, but not the ones currently deployed, + # so steal their implementation over here. + x = jwt.utils.base64url_decode(key.get("x")) + y = jwt.utils.base64url_decode(key.get("y")) + + try: + curve_obj = SECP256R1() + public_numbers = EllipticCurvePublicNumbers( + x=int.from_bytes(x, byteorder="big"), + y=int.from_bytes(y, byteorder="big"), + curve=curve_obj, + ) + + claims = jwt.decode(signed_jwt, public_numbers.public_key(), algorithms=['ES256']) + except jwt.exceptions.PyJWTError as e: + print("Exception validating jwt: {}".format(e)) + return False + except Exception as ee: + print("Exception processing jwt: {}".format(ee)) + return False + + if claims["iat"] < time.time() - 5 * 60: + print("Claim expired") + return False + + m = hashlib.sha256() + m.update(request.body) + body_hash = m.hexdigest() + + if not hmac.compare_digest(body_hash, claims['request_body_sha256']): + print("Hash of webhook did not validate") + return False + return True + + +@csrf_exempt +def webhook(request, methodid): + if request.method != 'POST': + raise Http404() + + if 'application/json' not in request.META['CONTENT_TYPE']: + print(request.META['CONTENT_TYPE']) + return HttpResponse("Invalid content type", status=400) + + try: + j = json.loads(request.body) + except json.decoder.JSONDecodeError: + return HttpResponse("Invalid json", status=400) + + # Store a copy of the webhook, for tracing + PlaidWebhookData( + source=request.META['REMOTE_ADDR'], + signature=request.META.get('HTTP_PLAID_VERIFICATION', ''), + hook_code=j.get('webhook_code', None), + contents=j, + ).save() + + # Process any type of webhook we know what to do with + + if j.get('webhook_type', None) == 'TRANSACTIONS' and j.get('webhook_code', None) == 'SYNC_UPDATES_AVAILABLE': + # Just ensure the object exists, and then throw it away, since we + # don't have a way to pass parameters to the job. We assume the + # number of plaid accounts to poll is never *that* big, and it's not + # like we expects several of these hooks to arrive per minute or so.. + method = get_object_or_404(InvoicePaymentMethod, pk=methodid, classname="postgresqleu.util.payment.plaid.Plaid") + if not _validate_signature(request, method): + return HttpResponse("Invalid signature", status=400) + + trigger_immediate_job_run('plaid_fetch_transactions') + + return HttpResponse("OK", content_type="text/plain") diff --git a/postgresqleu/settings.py b/postgresqleu/settings.py index 22957252..45ec270a 100644 --- a/postgresqleu/settings.py +++ b/postgresqleu/settings.py @@ -118,6 +118,7 @@ INSTALLED_APPS = [ 'postgresqleu.braintreepayment', 'postgresqleu.stripepayment', 'postgresqleu.transferwise', + 'postgresqleu.plaid', 'postgresqleu.membership', ] @@ -257,6 +258,12 @@ TREASURER_EMAIL = DEFAULT_EMAIL # performance overhead) is used. RELOAD_WATCH_DIRECTORIES = [] +# If using plaid, control which countries are enable when adding accounts +# (must match account cofig) +PLAID_COUNTRIES = ['US', 'CA'] +# Plaid production level (sandbox, development or production) +PLAID_LEVEL = 'development' + # If using the web based meetings, base URL for the web sockets server that # handles the messages. # Typically something like wss://some.domain.org/ws/meeting diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py index 6009e277..fe3f482c 100644 --- a/postgresqleu/urls.py +++ b/postgresqleu/urls.py @@ -31,6 +31,8 @@ import postgresqleu.trustlypayment.views import postgresqleu.braintreepayment.views import postgresqleu.stripepayment.views import postgresqleu.transferwise.views +import postgresqleu.plaid.backendviews +import postgresqleu.plaid.views import postgresqleu.accountinfo.views import postgresqleu.util.docsviews import postgresqleu.mailqueue.backendviews @@ -324,6 +326,7 @@ urlpatterns.extend([ url(r'^admin/invoices/bankfiles/$', postgresqleu.invoices.backendviews.bankfiles), url(r'^admin/invoices/bankfiles/transactions/$', postgresqleu.invoices.backendviews.bankfile_transaction_methodchoice), url(r'^admin/invoices/bankfiles/transactions/(\d+)/$', postgresqleu.invoices.backendviews.bankfile_transactions), + url(r'^admin/invoices/paymentmethods/(\d+)/plaidconnect/$', postgresqleu.plaid.backendviews.connect_to_plaid), url(r'^admin/invoices/paymentmethods/(.*/)?$', postgresqleu.invoices.backendviews.edit_paymentmethod), url(r'^invoices/trustlypay/(\d+)/(\d+)/(\w+)/$', postgresqleu.trustlypayment.views.invoicepayment_secret), url(r'^trustly_notification/(\d+)/$', postgresqleu.trustlypayment.views.notification), @@ -381,6 +384,9 @@ urlpatterns.extend([ # Transferwise webhooks url(r'^wh/tw/(\d+)/(\w+)/$', postgresqleu.transferwise.views.webhook), + # Plaid webhooks + url(r'^wh/plaid/(\d+)/$', postgresqleu.plaid.views.webhook), + # Digital signatures webhooks url(r'^wh/(sw)/(\d+)/$', postgresqleu.digisign.views.webhook), diff --git a/postgresqleu/util/payment/__init__.py b/postgresqleu/util/payment/__init__.py index 40d9ac55..adb92a36 100644 --- a/postgresqleu/util/payment/__init__.py +++ b/postgresqleu/util/payment/__init__.py @@ -29,6 +29,7 @@ payment_implementations = [ 'postgresqleu.util.payment.transferwise.Transferwise', 'postgresqleu.util.payment.stripe.Stripe', 'postgresqleu.util.payment.banktransfer.GenericManagedBankPayment', + 'postgresqleu.util.payment.plaid.Plaid', ] diff --git a/postgresqleu/util/payment/plaid.py b/postgresqleu/util/payment/plaid.py new file mode 100644 index 00000000..c99d3951 --- /dev/null +++ b/postgresqleu/util/payment/plaid.py @@ -0,0 +1,217 @@ +from django import forms +from django.http import HttpResponseRedirect +from django.conf import settings + +import io + +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") + connect = SubmitButtonField(label="Connect to plaid", required=False) + connection = forms.CharField(label='Connection', required=False, widget=StaticTextWidget) + + config_readonly = ['connect', 'connection', ] + managed_fields = ['description', 'clientid', 'secret', 'connect', 'connection', 'notification_receiver', 'notify_each_transaction', ] + managed_fieldsets = [ + { + 'id': 'plaid', + 'legend': 'Plaid', + 'fields': ['clientid', 'secret', ], + }, + { + 'id': 'notifications', + 'legend': 'Notifications', + 'fields': ['notification_receiver', 'notify_each_transaction', ], + }, + { + 'id': 'connection', + 'legend': 'Connection', + 'fields': ['connect', 'connection', ], + }, + ] + + @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 fetched4, so this account is a no-op' + + if 'accountid' in self.instance.config: + self.initial['connection'] = 'Connected to plaid account <code>{}</code>.'.format(self.instance.config['accountid']) + self.fields['connect'].widget.label = "Already connected" + self.fields['connect'].widget.attrs['disabled'] = True + else: + self.fields['connect'].callback = self.connect_to_plaid + self.initial['connection'] = 'Not connected.' + + 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/") + + +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", '<br/>') + + def get_link_token(self): + r = self.session.post('{}link/token/create'.format(self.ROOTURL), json={ + '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), + }, 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) + 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'])) + for t in j['removed']: + notes.write("Transaction {} removed, can't process.\n".format(t['transaction_id'])) + + 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 |