diff options
| author | Magnus Hagander | 2019-07-05 20:42:30 +0000 |
|---|---|---|
| committer | Magnus Hagander | 2019-07-08 09:47:03 +0000 |
| commit | 00adf6940d1fb86bce899ee023ae4793d0bdd817 (patch) | |
| tree | b7a47191a23272d5b864f4672ffc257c3b03d96c /postgresqleu/stripepayment | |
| parent | 758be702a4ea3bf2f68738178a1f1764d49f6868 (diff) | |
Initial support for Stripe payments
No support for payout tracking yet, since Stripe makes it impossible to
test that until 7 days after you sign up for the test account...
Diffstat (limited to 'postgresqleu/stripepayment')
| -rw-r--r-- | postgresqleu/stripepayment/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/admin.py | 23 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/api.py | 140 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/management/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/management/commands/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/management/commands/stripe_nightly.py | 100 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/management/commands/stripe_update_transactions.py | 29 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/migrations/0001_initial.py | 68 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/migrations/__init__.py | 0 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/models.py | 42 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/util.py | 55 | ||||
| -rw-r--r-- | postgresqleu/stripepayment/views.py | 208 |
12 files changed, 665 insertions, 0 deletions
diff --git a/postgresqleu/stripepayment/__init__.py b/postgresqleu/stripepayment/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/stripepayment/__init__.py diff --git a/postgresqleu/stripepayment/admin.py b/postgresqleu/stripepayment/admin.py new file mode 100644 index 00000000..41819411 --- /dev/null +++ b/postgresqleu/stripepayment/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import StripeCheckout, StripeLog + + +class StripeCheckoutAdmin(admin.ModelAdmin): + list_display = ('id', 'invoiceid', 'amount', 'fee', 'createdat', 'completedat', ) + + +class StripeLogAdmin(admin.ModelAdmin): + list_display = ('timestamp', 'success', 'sentstr', 'message', ) + + def success(self, obj): + return not obj.error + success.boolean = True + + def sentstr(self, obj): + return obj.sent and 'Yes' or 'No' + sentstr.short_description = 'Log sent' + + +admin.site.register(StripeCheckout, StripeCheckoutAdmin) +admin.site.register(StripeLog, StripeLogAdmin) diff --git a/postgresqleu/stripepayment/api.py b/postgresqleu/stripepayment/api.py new file mode 100644 index 00000000..28d19e6c --- /dev/null +++ b/postgresqleu/stripepayment/api.py @@ -0,0 +1,140 @@ +from django.conf import settings + +import datetime +from decimal import Decimal +import requests +from requests.auth import HTTPBasicAuth + +from .models import StripeCheckout, StripeRefund + + +class StripeException(Exception): + pass + + +class StripeApi(object): + APIBASE = "https://api.stripe.com/v1/" + + def __init__(self, pm): + self.published_key = pm.config('published_key') + self.secret_key = pm.config('secret_key') + + def _api_encode(self, params): + for key, value in params.items(): + if isinstance(value, list) or isinstance(value, tuple): + for i, subval in enumerate(value): + if isinstance(subval, dict): + subdict = self._encode_nested_dict("%s[%d]" % (key, i), subval) + yield from self._api_encode(subdict) + else: + yield ("%s[%d]" % (key, i), subval) + elif isinstance(value, dict): + subdict = self._encode_nested_dict(key, value) + yield from self._api_encode(subdict) + elif isinstance(value, datetime.datetime): + yield (key, self._encode_datetime(value)) + else: + yield (key, value) + + def _encode_nested_dict(self, key, data, fmt="%s[%s]"): + d = {} + for subkey, subvalue in data.items(): + d[fmt % (key, subkey)] = subvalue + return d + + def secret(self, suburl, params=None, raise_for_status=True): + if params: + r = requests.post(self.APIBASE + suburl, + list(self._api_encode(params)), + auth=HTTPBasicAuth(self.secret_key, ''), + ) + else: + r = requests.get(self.APIBASE + suburl, + auth=HTTPBasicAuth(self.secret_key, ''), + ) + if raise_for_status: + r.raise_for_status() + return r + + def get_balance(self): + r = self.secret('balance').json() + print(r) + balance = Decimal(0) + + for a in r['available']: + if a['currency'].lower() == settings.CURRENCY_ISO.lower(): + balance += Decimal(a['amount']) / 100 + break + else: + raise StripeException("No available balance entry found for currency {}".format(settings.CURRENCY_ISO)) + + for p in r['pending']: + if p['currency'].lower() == settings.CURRENCY_ISO.lower(): + balance += Decimal(p['amount']) / 100 + break + else: + raise StripeException("No pending balance entry found for currency {}".format(settings.CURRENCY_ISO)) + + return balance + + def update_checkout_status(self, co): + # Update the status of a payment. If it switched from unpaid to paid, + # return True, otherwise False. + if co.completedat: + # Already completed! + return False + + # We have to check the payment intent to get all the data that we + # need, so we don't bother checking the co itself. + + r = self.secret('payment_intents/{}'.format(co.paymentintent)).json() + if r['status'] == 'succeeded': + # Before we flag it as done, we need to calculate the fees. Those we + # can only find by loking at the charges, and from there finding the + # corresponding balance transaction. + if len(r['charges']['data']) != 1: + raise StripeException("More than one charge found, not supported!") + c = r['charges']['data'][0] + if not c['paid']: + return False + if c['currency'].lower() != settings.CURRENCY_ISO.lower(): + raise StripeException("Found payment charge in currency {0}, expected {1}".format(c['currency'], settings.CURRENCY_ISO)) + + txid = c['balance_transaction'] + t = self.secret('balance/history/{}'.format(txid)).json() + if t['currency'].lower() != settings.CURRENCY_ISO.lower(): + raise StripeException("Found balance transaction in currency {0}, expected {1}".format(t['currency'], settings.CURRENCY_ISO)) + if t['exchange_rate']: + raise StripeException("Found balance transaction with exchange rate set!") + + co.fee = Decimal(t['fee']) / 100 + co.completedat = datetime.datetime.now() + co.save() + + return True + # Still nothing + return False + + def refund_transaction(self, co, amount, refundid): + # To refund we need to find the charge id. + r = self.secret('payment_intents/{}'.format(co.paymentintent)).json() + if len(r['charges']['data']) != 1: + raise StripeException("Number of charges is {}, not 1, don't know how to refund".format(len(r['charges']['data']))) + chargeid = r['charges']['data'][0]['id'] + + r = self.secret('refunds', { + 'charge': chargeid, + 'amount': int(amount * 100), + 'metadata': { + 'refundid': refundid, + }, + }).json() + + refund = StripeRefund(paymentmethod=co.paymentmethod, + chargeid=chargeid, + invoicerefundid=refundid, + amount=amount, + refundid=r['id']) + refund.save() + + return r['id'] diff --git a/postgresqleu/stripepayment/management/__init__.py b/postgresqleu/stripepayment/management/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/stripepayment/management/__init__.py diff --git a/postgresqleu/stripepayment/management/commands/__init__.py b/postgresqleu/stripepayment/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/stripepayment/management/commands/__init__.py diff --git a/postgresqleu/stripepayment/management/commands/stripe_nightly.py b/postgresqleu/stripepayment/management/commands/stripe_nightly.py new file mode 100644 index 00000000..a5855429 --- /dev/null +++ b/postgresqleu/stripepayment/management/commands/stripe_nightly.py @@ -0,0 +1,100 @@ +# +# This script handles general nightly jobs for the Stripe integration: +# +# * Expire old checkout sessions +# * Compare balance to accounting +# * Check for stalled refunds +# * Send logs +# +# Copyright (C) 2019, PostgreSQL Europe +# + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.conf import settings + +from datetime import time, datetime, timedelta + +from postgresqleu.invoices.models import InvoicePaymentMethod +from postgresqleu.stripepayment.models import StripeCheckout + + +class Command(BaseCommand): + help = 'Stripe payment nightly job' + + class ScheduledJob: + scheduled_times = [time(3, 00), ] + + @classmethod + def should_run(self): + return InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.stripe.StripePayment').exists() + + def handle(self, *args, **options): + for method in InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.stripe.StripePayment'): + self.handle_one_account(method) + + @transaction.atomic + def handle_one_account(self, method): + pm = method.get_implementation() + + self.expire_sessions(method, pm) + self.verify_balance(method, pm) + self.check_refunds(method, pm) + self.send_logs(method, pm) + + def expire_sessions(self, method, pm): + # If there are any sessions that have not been touched for 48+ hours, then clearly something + # went wrong, so just get rid of them. + for co in StripeCheckout.objects.filter(method=method, completedat__isnull=True, createdat__lt=datetime.now() - timedelta(hours=48)): + StripeLog(message="Expired checkout session {0} (id {1}), not completed for 48 hours.".format(co.id, co.sessionid), + paymentmethod=method).save() + co.delete() + + def verify_balance(self, method, pm): + # Verify the balance against Stripe + api = StripeApi(pm) + stripe_balance = api.get_balance() + accounting_balance = get_latest_account_balance(pm.config('accounting_income')) + + if accounting_balance != stripe_balance: + send_simple_mail(settings.INVOICE_SENDER_EMAIL, + pm.config('notification_receiver'), + 'Stripe balance mismatch!', + """Stripe balance ({0}) for {1} does not match the accounting system ({2})! + +This could be because some entry has been missed in the accounting +(automatic or manual), or because of an ongoing booking of something +that the system doesn't know about. + +Better go check manually! +""".format(stripe_balance, method.internaldescription, accounting_balance)) + + def check_refunds(self, method, pm): + for r in StripeRefund.objects.filter(paymentmethod=method, + completedat__isnull=False, + invoicerefund__issued__lt=datetime.now() - timedelta(hours=6)): + + send_simle_mail(settings.INVOICE_SENDER_EMAIL, + pm.config('notification_receiver'), + 'Stripe stalled refund!', + """Stripe refund {0} for {1} has been stalled for more than 6 hours! + +This is probably not normal and should be checked! +""".format(r.id, method.internaldescription)) + + def send_logs(self, method, pm): + # Send logs for this account + lines = list(StripeLog.objects.filter(error=True, sent=False, paymentmethod=method).order_by('timestamp')) + if len(lines): + sio = StringIO() + sio.write("The following error events have been logged by the Stripe integration for {0}:\n\n".format(method.internaldescription)) + for l in lines: + sio.write("%s: %s\n" % (l.timestamp, l.message)) + l.sent = True + l.save() + sio.write("\n\n\nAll these events have now been tagged as sent, and will no longer be\nprocessed by the system in any way.\n") + + send_simple_mail(settings.INVOICE_SENDER_EMAIL, + pm.config('notification_receiver'), + 'Stripe integration error report', + sio.getvalue()) diff --git a/postgresqleu/stripepayment/management/commands/stripe_update_transactions.py b/postgresqleu/stripepayment/management/commands/stripe_update_transactions.py new file mode 100644 index 00000000..81945c7a --- /dev/null +++ b/postgresqleu/stripepayment/management/commands/stripe_update_transactions.py @@ -0,0 +1,29 @@ +# +# This script updates pending Stripe transactions, in the event +# the webhook has not been sent or not been properly processed. +# + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.conf import settings + +from datetime import time, datetime, timedelta + +from postgresqleu.stripepayment.models import StripeCheckout +from postgresqleu.stripepayment.util import process_stripe_checkout + + +class Command(BaseCommand): + help = 'Update Stripe transactions' + + class ScheduledJob: + scheduled_interval = timedelta(hours=4) + + @classmethod + def should_run(self): + if InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.stripe.StripePayment').exists(): + return StripeCheckout.objects.filter(completedat__isnull=True).exists() + + def handle(self, *args, **options): + for co in StripeCheckout.objects.filter(completedat__isnull=True): + process_stripe_checkout(co) diff --git a/postgresqleu/stripepayment/migrations/0001_initial.py b/postgresqleu/stripepayment/migrations/0001_initial.py new file mode 100644 index 00000000..791e3c08 --- /dev/null +++ b/postgresqleu/stripepayment/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-07-05 20:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('invoices', '0014_return_banktransaction'), + ] + + operations = [ + migrations.CreateModel( + name='ReturnAuthorizationStatus', + fields=[ + ('checkoutid', models.IntegerField(primary_key=True, serialize=False)), + ('seencount', models.IntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='StripeCheckout', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('createdat', models.DateTimeField()), + ('invoiceid', models.IntegerField(unique=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('sessionid', models.CharField(max_length=200, unique=True)), + ('paymentintent', models.CharField(max_length=200, unique=True)), + ('completedat', models.DateTimeField(blank=True, null=True)), + ('fee', models.DecimalField(decimal_places=2, max_digits=20, null=True)), + ('paymentmethod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.InvoicePaymentMethod')), + ], + options={ + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='StripeLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('message', models.TextField()), + ('error', models.BooleanField(default=False)), + ('sent', models.BooleanField(default=False)), + ('paymentmethod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.InvoicePaymentMethod')), + ], + options={ + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='StripeRefund', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chargeid', models.CharField(max_length=200)), + ('refundid', models.CharField(max_length=200, unique=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('completedat', models.DateTimeField(blank=True, null=True)), + ('invoicerefundid', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='invoices.InvoiceRefund')), + ('paymentmethod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.InvoicePaymentMethod')), + ], + ), + ] diff --git a/postgresqleu/stripepayment/migrations/__init__.py b/postgresqleu/stripepayment/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/postgresqleu/stripepayment/migrations/__init__.py diff --git a/postgresqleu/stripepayment/models.py b/postgresqleu/stripepayment/models.py new file mode 100644 index 00000000..fad11b73 --- /dev/null +++ b/postgresqleu/stripepayment/models.py @@ -0,0 +1,42 @@ +from django.db import models + +from postgresqleu.invoices.models import InvoicePaymentMethod, InvoiceRefund + + +class StripeCheckout(models.Model): + paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False) + createdat = models.DateTimeField(null=False, blank=False) + invoiceid = models.IntegerField(null=False, blank=False, unique=True) + amount = models.DecimalField(decimal_places=2, max_digits=20, null=False) + sessionid = models.CharField(max_length=200, null=False, blank=False, unique=True) + paymentintent = models.CharField(max_length=200, null=False, blank=False, unique=True) + completedat = models.DateTimeField(null=True, blank=True) + fee = models.DecimalField(decimal_places=2, max_digits=20, null=True) + + class Meta: + ordering = ('-id', ) + + +class StripeRefund(models.Model): + paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False) + chargeid = models.CharField(max_length=200, null=False, blank=False) + refundid = models.CharField(max_length=200, null=False, blank=False, unique=True) + invoicerefundid = models.OneToOneField(InvoiceRefund, null=False, blank=False, unique=True) + amount = models.DecimalField(decimal_places=2, max_digits=20, null=False) + completedat = models.DateTimeField(null=True, blank=True) + + +class ReturnAuthorizationStatus(models.Model): + checkoutid = models.IntegerField(null=False, blank=False, primary_key=True) + seencount = models.IntegerField(null=False, default=0) + + +class StripeLog(models.Model): + timestamp = models.DateTimeField(null=False, blank=False, auto_now_add=True) + message = models.TextField(null=False, blank=False) + error = models.BooleanField(null=False, blank=False, default=False) + sent = models.BooleanField(null=False, blank=False, default=False) + paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False) + + class Meta: + ordering = ('-id', ) diff --git a/postgresqleu/stripepayment/util.py b/postgresqleu/stripepayment/util.py new file mode 100644 index 00000000..62f8b580 --- /dev/null +++ b/postgresqleu/stripepayment/util.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.db import transaction + +from postgresqleu.mailqueue.util import send_simple_mail +from postgresqleu.invoices.util import InvoiceManager +from postgresqleu.invoices.models import Invoice + +from .models import StripeLog +from .api import StripeApi, StripeException + + +def process_stripe_checkout(co): + if co.completedat: + # Already completed, so don't do anything with it + return + + with transaction.atomic(): + method = co.paymentmethod + pm = method.get_implementation() + api = StripeApi(pm) + + # Update the status from the API + if api.update_checkout_status(co): + # Went from unpaid to paid, so Do The Magic (TM) + manager = InvoiceManager() + invoice = Invoice.objects.get(pk=co.invoiceid) + + def invoice_logger(msg): + raise StripeException("Stripe invoice processing failed: {0}".format(msg)) + + manager.process_incoming_payment_for_invoice(invoice, + co.amount, + 'Stripe checkout id {0}'.format(co.id), + co.fee, + pm.config('accounting_income'), + pm.config('accounting_fee'), + [], + invoice_logger, + method) + + StripeLog(message="Completed payment for Stripe id {0} ({1}{2}, invoice {3})".format(co.id, settings.CURRENCY_ABBREV, co.amount, invoice.id), paymentmethod=method).save() + + send_simple_mail(settings.INVOICE_SENDER_EMAIL, + pm.config('notification_receiver'), + "Stripe payment completed", + "A Stripe payment for {0} of {1}{2} for invoice {3} was completed.\n\nInvoice: {4}\nRecipient name: {5}\nRecipient email: {6}\n".format( + method.internaldescription, + settings.CURRENCY_ABBREV, + co.amount, + invoice.id, + invoice.title, + invoice.recipient_name, + invoice.recipient_email, + ) + ) diff --git a/postgresqleu/stripepayment/views.py b/postgresqleu/stripepayment/views.py new file mode 100644 index 00000000..38963f38 --- /dev/null +++ b/postgresqleu/stripepayment/views.py @@ -0,0 +1,208 @@ +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.db import transaction +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings + +from datetime import datetime, timedelta +import json +import hmac +import hashlib + +from postgresqleu.invoices.models import Invoice, InvoicePaymentMethod +from postgresqleu.invoices.util import InvoiceManager + +from .models import StripeCheckout, StripeRefund, StripeLog +from .models import ReturnAuthorizationStatus +from .api import StripeApi +from .util import process_stripe_checkout + + +@transaction.atomic +def invoicepayment_secret(request, paymentmethod, invoiceid, secret): + method = get_object_or_404(InvoicePaymentMethod, pk=paymentmethod, active=True) + invoice = get_object_or_404(Invoice, pk=invoiceid, deleted=False, finalized=True, recipient_secret=secret) + + pm = method.get_implementation() + + api = StripeApi(pm) + + try: + co = StripeCheckout.objects.get(invoiceid=invoice.id) + if co.completedat: + # Session exists and is completed! Redirect to the invoice page that shows + # the results. + # This is a case that normally shouldn't happen, but can for example if a + # user has multiple tabs open. + return HttpResponseRedirect("/invoices/{0}/{1}/".format(invoice.invoiceid, invoice.recipient_secret)) + + # Else session exists but is not completed, so send it through back to Stripe + # again. + except StripeCheckout.DoesNotExist: + # Create a new checkout session + co = StripeCheckout(createdat=datetime.now(), + paymentmethod=method, + invoiceid=invoice.id, + amount=invoice.total_amount) + + # Generate the session + r = api.secret('checkout/sessions', { + 'cancel_url': '{0}/invoices/stripepay/{1}/{2}/{3}/cancel/'.format(settings.SITEBASE, paymentmethod, invoiceid, secret), + 'success_url': '{0}/invoices/stripepay/{1}/{2}/{3}/results/'.format(settings.SITEBASE, paymentmethod, invoiceid, secret), + 'payment_method_types': ['card', ], + 'client_reference_id': invoice.id, + 'line_items': [ + { + 'amount': int(invoice.total_amount * 100), + 'currency': settings.CURRENCY_ISO, + 'name': '{0} invoice #{1}'.format(settings.ORG_NAME, invoice.id), + 'quantity': 1, + }, + ], + 'customer_email': invoice.recipient_email, + 'payment_intent_data': { + 'capture_method': 'automatic', + 'statement_descriptor': '{0} invoice {1}'.format(settings.ORG_SHORTNAME, invoice.id), + }, + }) + if r.status_code != 200: + return HttpResponse("Unable to create Stripe payment session: {}".format(r.status_code)) + j = r.json() + co.sessionid = j['id'] + co.paymentintent = j['payment_intent'] + co.save() + + return render(request, 'stripepayment/payment.html', { + 'invoice': invoice, + 'stripekey': pm.config('published_key'), + 'sessionid': co.sessionid, + }) + + +def invoicepayment_results(request, paymentmethod, invoiceid, secret): + # Get the invoice so we can be sure that we have the secret + get_object_or_404(Invoice, id=invoiceid, recipient_secret=secret) + co = get_object_or_404(StripeCheckout, invoiceid=invoiceid) + + if co.completedat: + # Payment is completed! + return HttpResponseRedirect("/invoices/{0}/{1}/".format(invoiceid, secret)) + + # Else we need to loop on this page. To handle this we create + # a temporary object so we can increase the waiting time + status, created = ReturnAuthorizationStatus.objects.get_or_create(checkoutid=co.id) + status.seencount += 1 + status.save() + return render(request, 'stripepayment/pending.html', { + 'refresh': 3 * status.seencount, + 'url': '/invoices/stripepay/{0}/{1}/{2}/'.format(paymentmethod, invoiceid, secret), + 'createdat': co.createdat, + }) + + +def invoicepayment_cancel(request, paymentmethod, invoiceid, secret): + # Get the invoice so we can be sure that we have the secret + get_object_or_404(Invoice, id=invoiceid, recipient_secret=secret) + co = get_object_or_404(StripeCheckout, invoiceid=invoiceid) + method = InvoicePaymentMethod.objects.get(pk=paymentmethod, classname="postgresqleu.util.payment.stripe.Stripe") + + if not co.completedat: + # Payment is not completed, so delete ours session. + # Stripe API has no way to delete it on their end, but as soon as we have + # removed our reference to it, it will never be used again. + with transaction.atomic(): + StripeLog(message="Payment for Stripe checkout {0} (id {1}) canceled, removing.".format(co.id, co.sessionid), + paymentmethod=method).save() + co.delete() + + # Send the user back to the invoice to pick another payment method (optionally) + return HttpResponseRedirect("/invoices/{0}/{1}/".format(invoiceid, secret)) + + +@csrf_exempt +def webhook(request, methodid): + sig = request.META['HTTP_STRIPE_SIGNATURE'] + try: + payload = json.loads(request.body.decode('utf8', errors='ignore')) + except ValueError: + return HttpResponse("Invalid JSON", status=400) + + method = InvoicePaymentMethod.objects.get(pk=methodid, classname="postgresqleu.util.payment.stripe.Stripe") + pm = method.get_implementation() + + sigdata = dict([v.strip().split('=') for v in sig.split(',')]) + + sigstr = sigdata['t'] + '.' + request.body.decode('utf8', errors='ignore') + mac = hmac.new(pm.config('webhook_secret').encode('utf8'), + msg=sigstr.encode('utf8'), + digestmod=hashlib.sha256) + if mac.hexdigest() != sigdata['v1']: + return HttpResponse("Invalid signature", status=400) + + # Signature is OK, figure out what to do + if payload['type'] == 'checkout.session.completed': + sessionid = payload['data']['object']['id'] + try: + co = StripeCheckout.objects.get(sessionid=sessionid) + except StripeCheckout.DoesNotExist: + StripeLog(message="Received completed session event for non-existing sessions {}".format(sessionid), + error=True, + paymentmethod=method).save() + return HttpResponse("OK") + + # We don't get enough data in the session, unfortunately, so we have to + # make some incoming API calls. + StripeLog(message="Received Stripe webhook for checkout {}. Processing.".format(co.id), paymentmethod=method).save() + process_stripe_checkout(co) + StripeLog(message="Completed processing webhook for checkout {}.".format(co.id), paymentmethod=method).save() + return HttpResponse("OK") + elif payload['type'] == 'charge.refunded': + chargeid = payload['data']['object']['id'] + + # There can be multiple refunds on each charge, so we have to look through all the + # possible ones, and compare. Unfortunately, there is no notification available which + # tells us *which one* was completed. Luckily there will never be *many* refunds on a + # single charge. + with transaction.atomic(): + for r in payload['data']['object']['refunds']['data']: + try: + refund = StripeRefund.objects.get(paymentmethod=method, + chargeid=chargeid, + refundid=r['id']) + except StripeRefund.DoesNotExist: + StripeLog(message="Received completed refund event for non-existant refund {}".format(r['id']), + error=True, + paymentmethod=method).save() + return HttpResponse("OK") + if refund.completedat: + # It wasn't this one, as it's already been completed. + continue + + if r['amount'] != refund.amount * 100: + StripeLog(message="Received completed refund with amount {0} instead of expected {1} for refund {2}".format(r['amount'] / 100, refund.amount, refund.id), + error=True, + paymentmethod=method).save() + return HttpResponse("OK") + + # OK, refund looks fine + StripeLog(message="Received Stripe webhook for refund {}. Processing.".format(refund.id), paymentmethod=method).save() + + refund.completedat = datetime.now() + refund.save() + + manager = InvoiceManager() + manager.complete_refund( + refund.invoicerefundid, + -refund.amount, + 0, # Unknown fee + pm.config('accounting_income'), + pm.config('accounting_fee'), + [], + method) + return HttpResponse("OK") + else: + StripeLog(message="Received unknown Stripe event type '{}'".format(payload['type']), + error=True, + paymentmethod=method).save() + # We still flag it as OK to stripe + return HttpResponse("OK") |
