diff --git a/src/shop/coinify.py b/src/shop/coinify.py new file mode 100644 index 00000000..3e7910ad --- /dev/null +++ b/src/shop/coinify.py @@ -0,0 +1,148 @@ +from vendor.coinify.coinify_api import CoinifyAPI +from vendor.coinify.coinify_callback import CoinifyCallback +from .models import CoinifyAPIRequest, CoinifyAPIInvoice +import json, logging + +logger = logging.getLogger("bornhack.%s" % __name__) + + +def process_coinify_invoice_json(invoicejson, order): + # create or update the invoice object in our database + coinifyinvoice, created = CoinifyAPIInvoice.objects.update_or_create( + coinify_id=invoicejson['id'], + order=order, + defaults={ + invoicejson: invoicejson + }, + ) + + # if the order is paid in full call the mark as paid method now + if invoicejson['state'] == 'complete': + coinifyinvoice.order.mark_as_paid() + + return coinifyinvoice + + +def save_coinify_callback(request): + # first make a dict with all HTTP_ headers + headerdict = {} + for key, value in list(request.META.items()): + if key[:5] == 'HTTP_': + headerdict[key[5:]] = value + + # now attempt to parse json + try: + parsed = json.loads(request.body.decode('utf-8')) + except Exception as E: + parsed = '' + + # save this callback to db + callbackobject = CoinifyAPICallback.objects.create( + headers=headerdict, + body=request.body, + payload=parsed, + order=self.get_object(), + ) + + return callbackobject + + +def coinify_api_request(api_method, order, **kwargs): + # Initiate coinify API + coinifyapi = CoinifyAPI( + settings.COINIFY_API_KEY, + settings.COINIFY_API_SECRET + ) + + # is this a supported method? + if not hasattr(coinifyapi, api_method): + logger.error("coinify api method not supported" % api_method) + return False + + # get and run the API call using the SDK + method = getattr(coinifyapi, api_method) + + # catch requests exceptions as described in https://github.com/CoinifySoftware/python-sdk#catching-errors and + # http://docs.python-requests.org/en/latest/user/quickstart/#errors-and-exceptions + try: + response = method(**kwargs) + except requests.exceptions.RequestException as E: + logger.error("requests exception during coinify api request: %s" % E) + return False + + # save this API request to the database + req = CoinifyAPIRequest.objects.create( + order=order, + method=api_method, + payload=json.dumps(invoicedict), + response=json.dumps(response), + ) + logger.debug("saved coinify api request %s in db" % req.id) + + return req + + +def handle_coinify_api_response(req, order): + if req.method == 'invoice_create' or req.method == 'invoice_get': + # Parse api response + if req.response['success']: + # save this new coinify invoice to the DB + coinifyinvoice = process_coinify_invoice_json( + invoicejson = req.response['data'], + order = order, + ) + return coinifyinvoice + else: + api_error = req.response['error'] + logger.error("coinify API error: %s (%s)" % ( + api_error['message'], + api_error['code'] + )) + return False + else: + logger.error("coinify api method not supported" % req.method) + return False + + +################### API CALLS ################################################ + + +def get_coinify_invoice(coinify_invoiceid, order): + # put args for API request together + invoicedict = { + 'invoice_id': coinify_invoiceid + } + + # perform the api request + req = coinify_api_request( + api_method='invoice_get', + **invoicedict + ) + + coinifyinvoice = handle_coinify_api_response(req, order) + return coinifyinvoice + + +def create_coinify_invoice(order): + # put args for API request together + invoicedict = { + 'amount': float(order.total), + 'currency': 'DKK', + 'plugin_name': 'BornHack webshop', + 'plugin_version': '1.0', + 'description': 'BornHack order id #%s' % order.id, + 'callback_url': order.get_coinify_callback_url(request), + 'return_url': order.get_coinify_thanks_url(request), + 'cancel_url': order.get_cancel_url(request), + } + + # perform the API request + req = coinify_api_request( + api_method='invoice_create', + order=order, + **invoicedict + ) + + coinifyinvoice = handle_coinify_api_response(req, order) + return coinifyinvoice + diff --git a/src/shop/migrations/0042_auto_20170507_1000.py b/src/shop/migrations/0042_auto_20170507_1000.py new file mode 100644 index 00000000..3bb8fe2b --- /dev/null +++ b/src/shop/migrations/0042_auto_20170507_1000.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-05-07 08:00 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0041_auto_20170408_1104'), + ] + + operations = [ + migrations.AlterField( + model_name='coinifyapiinvoice', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='coinify_api_invoices', to='shop.Order'), + ), + ] diff --git a/src/shop/migrations/0043_auto_20170507_1309.py b/src/shop/migrations/0043_auto_20170507_1309.py new file mode 100644 index 00000000..414d8af7 --- /dev/null +++ b/src/shop/migrations/0043_auto_20170507_1309.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-05-07 11:09 +from __future__ import unicode_literals + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0042_auto_20170507_1000'), + ] + + operations = [ + migrations.CreateModel( + name='CoinifyAPIRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('payload', django.contrib.postgres.fields.jsonb.JSONField()), + ('response', models.TextField()), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='coinify_api_requests', to='shop.Order')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='coinifyapicallback', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='coinify_api_callbacks', to='shop.Order'), + ), + ] diff --git a/src/shop/migrations/0044_coinifyapiinvoice_coinify_id.py b/src/shop/migrations/0044_coinifyapiinvoice_coinify_id.py new file mode 100644 index 00000000..54c8a18d --- /dev/null +++ b/src/shop/migrations/0044_coinifyapiinvoice_coinify_id.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-05-07 13:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0043_auto_20170507_1309'), + ] + + operations = [ + migrations.AddField( + model_name='coinifyapiinvoice', + name='coinify_id', + field=models.IntegerField(null=True), + ), + ] diff --git a/src/shop/migrations/0045_auto_20170507_1648.py b/src/shop/migrations/0045_auto_20170507_1648.py new file mode 100644 index 00000000..695e3ba4 --- /dev/null +++ b/src/shop/migrations/0045_auto_20170507_1648.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-05-07 14:48 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0044_coinifyapiinvoice_coinify_id'), + ] + + operations = [ + migrations.RenameField( + model_name='coinifyapicallback', + old_name='valid', + new_name='authenticated', + ), + ] diff --git a/src/shop/migrations/0046_coinifyapirequest_method.py b/src/shop/migrations/0046_coinifyapirequest_method.py new file mode 100644 index 00000000..2710bf59 --- /dev/null +++ b/src/shop/migrations/0046_coinifyapirequest_method.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-05-07 17:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0045_auto_20170507_1648'), + ] + + operations = [ + migrations.AddField( + model_name='coinifyapirequest', + name='method', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + ] diff --git a/src/shop/models.py b/src/shop/models.py index 7bfa0305..7a5c4d64 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -14,6 +14,8 @@ import hashlib, io, base64, qrcode from decimal import Decimal from datetime import timedelta from unidecode import unidecode +from django.utils.dateparse import parse_datetime +from django.utils import timezone class CustomOrder(CreatedUpdatedModel): @@ -407,6 +409,10 @@ class CoinifyAPIInvoice(CreatedUpdatedModel): def __str__(self): return "coinifyinvoice for order #%s" % self.order.id + @property + def expired(self): + return parse_datetime(self.invoicejson['expire_time']) < timezone.now() + class CoinifyAPICallback(CreatedUpdatedModel): headers = JSONField() diff --git a/src/shop/views.py b/src/shop/views.py index f7d51de2..ff5ff6ac 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -534,51 +534,21 @@ class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUn def dispatch(self, request, *args, **kwargs): order = self.get_object() + # check if we already have a coinifyinvoice for this order if hasattr(order, 'coinifyapiinvoice'): - # we already have a coinifyinvoice for this order, - # check if it expired - if parse_datetime(order.coinifyapiinvoice.invoicejson['expire_time']) < timezone.now(): - # this coinifyinvoice expired, delete it - logger.warning("deleting expired coinifyinvoice id %s" % order.coinifyapiinvoice.invoicejson['id']) - order.coinifyapiinvoice.delete() - order.refresh_from_db() + # we already have a coinifyinvoice for this order, check if it expired + if order.coinifyapiinvoice.expired: + # this coinifyinvoice expired, we need a new one + logger.warning("coinifyinvoice id %s expired" % order.coinifyapiinvoice.invoicejson['id']) + order.coinifyapiinvoice = None # create a new coinify invoice if needed if not hasattr(order, 'coinifyapiinvoice'): - # Initiate coinify API - coinifyapi = CoinifyAPI( - settings.COINIFY_API_KEY, - settings.COINIFY_API_SECRET - ) - - # create coinify API - response = coinifyapi.invoice_create( - float(order.total), - 'DKK', - plugin_name='BornHack webshop', - plugin_version='1.0', - description='BornHack order id #%s' % order.id, - callback_url=order.get_coinify_callback_url(request), - return_url=order.get_coinify_thanks_url(request), - cancel_url=order.get_cancel_url(request), - ) - - # Parse response - if not response['success']: - api_error = response['error'] - logger.error("API error: %s (%s)" % ( - api_error['message'], - api_error['code'] - )) + coinifyinvoice = create_coinify_invoice(order) + if not coinifyinvoice: messages.error(request, "There was a problem with the payment provider. Please try again later") return HttpResponseRedirect(reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk})) - else: - # save this coinify invoice - coinifyinvoice = CoinifyAPIInvoice.objects.create( - invoicejson = response['data'], - order = order, - ) - logger.info("created new coinifyinvoice id %s" % coinifyinvoice.invoicejson['id']) + return super(CoinifyRedirectView, self).dispatch( request, *args, **kwargs ) @@ -595,67 +565,34 @@ class CoinifyCallbackView(SingleObjectMixin, View): return super(CoinifyCallbackView, self).dispatch(*args, **kwargs) def post(self, request, *args, **kwargs): - # Get the signature from the HTTP headers - signature = request.META['HTTP_X_COINIFY_CALLBACK_SIGNATURE'] - sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode('utf-8')) - - # make a dict with all HTTP_ headers - headerdict = {} - for key, value in list(request.META.items()): - if key[:5] == 'HTTP_': - headerdict[key[5:]] = value - - # attempt to parse json - try: - parsed = json.loads(request.body.decode('utf-8')) - except Exception as E: - parsed = '' - - # save callback to db - callbackobject = CoinifyAPICallback.objects.create( - headers=headerdict, - body=request.body, - payload=parsed, - order=self.get_object() - ) + # save callback and parse json payload + callbackobject = save_coinify_callback(request) # do we have a json body? - if not parsed: + if not callbackobject.payload: # no, return an error logger.error("unable to parse JSON body in callback for order %s" % callbackobject.order.id) return HttpResponseBadRequest('unable to parse json') + # initiate SDK + sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode('utf-8')) + # attemt to validate the callbackc - if sdk.validate_callback(request.body, signature): + if sdk.validate_callback(request.body, request.META['HTTP_X_COINIFY_CALLBACK_SIGNATURE']): # mark callback as valid in db callbackobject.valid=True callbackobject.save() - - if callbackobject.payload['event'] == 'invoice_state_change' or callbackobject.payload['event'] == 'invoice_manual_resend': - # find coinify invoice in db - try: - coinifyinvoice = CoinifyAPIInvoice.objects.get(invoicejson__id=callbackobject.payload['data']['id']) - except CoinifyAPIInvoice.DoesNotExist: - logger.error("unable to find CoinifyAPIInvoice with id %s" % callbackobject.payload['data']['id']) - return HttpResponseBadRequest('bad coinifyinvoice id') - - # save new coinifyinvoice payload - coinifyinvoice.invoicejson = callbackobject.payload['data'] - coinifyinvoice.save() - - # so, is the order paid in full now? - if callbackobject.payload['data']['state'] == 'complete': - coinifyinvoice.order.mark_as_paid() - - # return 200 OK - return HttpResponse('OK') - else: - logger.error("unsupported callback event %s" % callbackobject.payload['event']) - return HttpResponseBadRequest('unsupported event') else: logger.error("invalid coinify callback detected") return HttpResponseBadRequest('something is fucky') + if callbackobject.payload['event'] == 'invoice_state_change' or callbackobject.payload['event'] == 'invoice_manual_resend': + coinifyinvoice = process_coinify_invoice_json(invoicejson, self.get_object()) + return HttpResponse('OK') + else: + logger.error("unsupported callback event %s" % callbackobject.payload['event']) + return HttpResponseBadRequest('unsupported event') + class CoinifyThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView): model = Order