summaryrefslogtreecommitdiff
path: root/postgresqleu/stripepayment
diff options
context:
space:
mode:
authorMagnus Hagander2019-07-05 20:42:30 +0000
committerMagnus Hagander2019-07-08 09:47:03 +0000
commit00adf6940d1fb86bce899ee023ae4793d0bdd817 (patch)
treeb7a47191a23272d5b864f4672ffc257c3b03d96c /postgresqleu/stripepayment
parent758be702a4ea3bf2f68738178a1f1764d49f6868 (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__.py0
-rw-r--r--postgresqleu/stripepayment/admin.py23
-rw-r--r--postgresqleu/stripepayment/api.py140
-rw-r--r--postgresqleu/stripepayment/management/__init__.py0
-rw-r--r--postgresqleu/stripepayment/management/commands/__init__.py0
-rw-r--r--postgresqleu/stripepayment/management/commands/stripe_nightly.py100
-rw-r--r--postgresqleu/stripepayment/management/commands/stripe_update_transactions.py29
-rw-r--r--postgresqleu/stripepayment/migrations/0001_initial.py68
-rw-r--r--postgresqleu/stripepayment/migrations/__init__.py0
-rw-r--r--postgresqleu/stripepayment/models.py42
-rw-r--r--postgresqleu/stripepayment/util.py55
-rw-r--r--postgresqleu/stripepayment/views.py208
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")