Commits

Luke Plant committed 051f699

Implemented payment system using django-paypal

Comments (0)

Files changed (9)

cciw/bookings/models.py

 from datetime import datetime, date, timedelta
 from decimal import Decimal
 import os
+import re
 
 from dateutil.relativedelta import relativedelta
 from django.db import models
 from cciw.cciwmain.models import Camp
 from cciw.cciwmain.utils import Lock
 
+# = Business rules =
+#
+# Business rules are implemented in relevant models and managers.
+#
+#
+
+
 
 SEX_MALE, SEX_FEMALE = 'm', 'f'
 SEXES = [
 
     # Business methods:
 
-    def get_balance(self):
-        total = self.bookings.filter(state=BOOKING_BOOKED).aggregate(models.Sum('amount_due'))['amount_due__sum']
+    def get_balance(self, confirmed_only=False):
+        """
+        Gets the balance to pay on the account.
+        If confirmed_only=True, then only bookings that are confirmed
+        (no expiration date) are included as 'received goods'
+        """
+        if confirmed_only:
+            total = self.bookings.confirmed().aggregate(models.Sum('amount_due'))['amount_due__sum']
+
+        else:
+            total = self.bookings.booked().aggregate(models.Sum('amount_due'))['amount_due__sum']
         if total is None:
             total = Decimal('0.00')
         return total - self.total_received
 
+    def receive_payment(self, amount):
+        # = Receiving payments =
+        #
+        # This system needs to be robust, and cope with all kinds of user error, and
+        # things not matching up. The essential philosophy of this code is to assume the
+        # worst, most complicated scenario, and this will then easily handle the more
+        # simple case where everything matches up as a special case.
+        #
+        # When a payment is received, django-paypal creates an object
+        # and a signal handler calls BookingAccount.receive_payment
+        #
+        # We also need to set the 'Booking.booking_expires' field of relevant Booking
+        # objects. to null, so that the place is securely booked.
+        #
+        # There are a number of scenarios where the amount paid doesn't cover the total
+        # amount due:
+        # 1) user fiddles with the form client side and alters the amount
+        # 2) user starts paying for one place, then books another place in a different
+        # tab/window
+        #
+        # It is also possible for a place to be partially paid for, yet booked e.g. if a
+        # user selects a discount for which they were not eligible, and pays. This is then
+        # discovered, and the 'amount due' for that place is altered.
+        #
+        # So, we need a method to distribute any incoming payment so that we stop the
+        # booked place from expiring. It is better to be too generous than too stingy in
+        # stopping places from expiring, because:
+        #
+        # * on camp we can generally cope with one too many campers
+        # * we don't want people slipping off the camp lists by accident
+        # * we can always check whether people still have money outstanding by just checking
+        #   the total amount paid against the total amount due.
+        #
+        # Therefore, we ignore the partially paid case, and for distributing payment treat
+        # any place which is 'booked' with no 'booking_expires' as fully paid.
+        #
+        # When a payment is received, we don't know which place it is for, and in general
+        # it could be for any combination of the places that need payment. So, for
+        # simplicity we simply go through all places which are 'booked' and have a
+        # 'booking_expires' date, starting with the earliest 'booking_expires', on the
+        # assumption that we will get payment for that one first. If the amount for that
+        # place is less than or equal to incoming payment, we remove the
+        # 'booking_expires', deduct the amount from the incoming funds, and
+        # continue. Otherwise, we skip and continue.
+        #
+        # At the end, we check the outstanding balance, and any places that still have
+        # 'booking_expires' dates, and send an email as appropriate.
+
+        # Use update and F objects to avoid concurrency problems
+        BookingAccount.objects.filter(id=self.id).update(total_received=models.F('total_received') + amount)
+
+        # Need new data from DB, so get a fresh object
+        acc = BookingAccount.objects.get(id=self.id)
+        self.total_received = acc.total_received
+
+        # In order to distribute funds, need to take into account the total
+        # amount in the account that is not covered by confirmed places
+        existing_balance = acc.get_balance(confirmed_only=True)
+        # The 'pot' is the amount we have as excess and can use to mark places
+        # as confirmed.
+        pot = -existing_balance
+        # Order by booking_expires ascending i.e. earliest first
+        candidate_bookings = list(self.bookings.unconfirmed()
+                                  .order_by('booking_expires'))
+        i = 0
+        while pot > 0 and i < len(candidate_bookings):
+            b = candidate_bookings[i]
+            if b.amount_due <= pot:
+                b.confirm()
+                b.save()
+                pot -= b.amount_due
+            i += 1
+
 
 class BookingManager(models.Manager):
     use_for_related_fields = True
         qs = self.get_query_set().filter(camp__year__exact=year, shelved=shelved)
         return qs.filter(state=BOOKING_INFO_COMPLETE) | qs.filter(state=BOOKING_APPROVED)
 
+    def booked(self):
+        return self.get_query_set().filter(state=BOOKING_BOOKED)
+
     def confirmed(self):
-        return self.get_query_set().filter(state=BOOKING_BOOKED)
+        return self.get_query_set().filter(state=BOOKING_BOOKED,
+                                           booking_expires__isnull=True)
+
+    def unconfirmed(self):
+        return self.get_query_set().filter(state=BOOKING_BOOKED,
+                                           booking_expires__isnull=False)
 
 
 class Booking(models.Model):
                           % (self.camp.maximum_age, self.camp.year))
 
         # Check place availability
-        places_booked = self.camp.bookings.confirmed().count()
-        places_booked_male = self.camp.bookings.confirmed().filter(sex=SEX_MALE).count()
-        places_booked_female = self.camp.bookings.confirmed().filter(sex=SEX_FEMALE).count()
+        places_booked = self.camp.bookings.booked().count()
+        places_booked_male = self.camp.bookings.booked().filter(sex=SEX_MALE).count()
+        places_booked_female = self.camp.bookings.booked().filter(sex=SEX_FEMALE).count()
 
         # We only want one message about places not being available, and the
         # order here is important - if there are no places full stop, we don't
 
         return retval
 
+    def confirm(self):
+        self.booking_expires = None
+
     def is_user_editable(self):
         return self.state == BOOKING_INFO_COMPLETE
 
         return True
     finally:
         lock.release()
+
+
+def unrecognised_payment(ipn_obj):
+    # If an online payment does not reference an existing BookingAccount, we accept it
+    # but complain loudly by email.
+    pass # TODO
+
+
+def paypal_payment_received(sender, **kwargs):
+    ipn_obj = sender
+    m = re.match("account:(\d+);", ipn_obj.custom)
+    if m is None:
+        unrecognised_payment(ipn_obj)
+        return
+
+    try:
+        account = BookingAccount.objects.get(id=int(m.groups()[0]))
+        account.receive_payment(ipn_obj.mc_gross)
+    except BookingAccount.DoesNotExist:
+        unrecognised_payment(ipn_obj)
+
+
+# Payment signals
+from paypal.standard.ipn.signals import payment_was_successful
+payment_was_successful.connect(paypal_payment_received)

cciw/bookings/tests.py

 from django.test import TestCase
 from django.utils import simplejson
 
-from cciw.bookings.models import BookingAccount, Price, Booking
+from cciw.bookings.models import BookingAccount, Price, Booking, book_basket_now
 from cciw.bookings.models import PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, PRICE_SOUTH_WALES_TRANSPORT, BOOKING_APPROVED, BOOKING_INFO_COMPLETE, BOOKING_BOOKED
 from cciw.cciwmain.common import get_thisyear
 from cciw.cciwmain.models import Camp
         expected_price = 2 * Price.objects.get(year=get_thisyear(),
                                                price_type=PRICE_FULL).price
         self.assertContains(resp, '£%s' % expected_price)
+
+
+class TestPaymentReceived(CreatePlaceMixin, TestCase):
+
+    fixtures = ['basic.json']
+
+    def test_receive_payment(self):
+        self.login()
+        self.create_place()
+        acc = BookingAccount.objects.get(email=self.email)
+        book_basket_now(acc.bookings.basket(self.camp.year))
+        self.assertTrue(acc.bookings.all()[0].booking_expires is not None)
+
+        p = Price.objects.get(year=get_thisyear(), price_type=PRICE_FULL).price
+        acc.receive_payment(p)
+
+        acc = BookingAccount.objects.get(email=self.email)
+
+        # Check we updated the account
+        self.assertEqual(acc.total_received, p)
+
+        # Check we updated the bookings
+        self.assertTrue(acc.bookings.all()[0].booking_expires is None)
+
+    def test_insufficient_receive_payment(self):
+        self.login()
+        self.create_place()
+        self.create_place({'price_type': PRICE_2ND_CHILD})
+        acc = BookingAccount.objects.get(email=self.email)
+        book_basket_now(acc.bookings.basket(self.camp.year))
+        self.assertTrue(acc.bookings.all()[0].booking_expires is not None)
+
+        p1 = Price.objects.get(year=get_thisyear(), price_type=PRICE_FULL).price
+        p2 = Price.objects.get(year=get_thisyear(), price_type=PRICE_2ND_CHILD).price
+
+        # Between the two
+        p = (p1 + p2) / 2
+        acc.receive_payment(p)
+
+        # Check we updated the account
+        self.assertEqual(acc.total_received, p)
+
+        # Check we updated the one we had enough funds for
+        self.assertTrue(acc.bookings.filter(price_type=PRICE_2ND_CHILD)[0].booking_expires is None)
+        # but not the one which was too much.
+        self.assertTrue(acc.bookings.filter(price_type=PRICE_FULL)[0].booking_expires is not None)
+
+
+        # We can rectify it with a payment of the rest
+        acc.receive_payment((p1 + p2) - p)
+        self.assertTrue(acc.bookings.filter(price_type=PRICE_FULL)[0].booking_expires is None)
+

cciw/bookings/urls.py

              (r'^places-json/$', 'places_json'),
              (r'^checkout/$', 'list_bookings'),
              (r'^pay/$', 'pay'),
+             (r'^pay/done/$', 'pay_done'),
+             (r'^pay/cancelled/$', 'pay_cancelled'),
              )

cciw/bookings/views.py

 from django.utils.crypto import salted_hmac
 from django.views.generic.base import TemplateView, TemplateResponseMixin
 from django.views.generic.edit import ProcessFormView, FormMixin, ModelFormMixin, BaseUpdateView, BaseCreateView
+from paypal.standard.forms import PayPalPaymentsForm
 
-from cciw.cciwmain.common import get_thisyear, DefaultMetaData, AjaxyFormMixin
+from cciw.cciwmain.common import get_thisyear, DefaultMetaData, AjaxyFormMixin, get_current_domain
 from cciw.cciwmain.decorators import json_response
 from cciw.cciwmain.models import Camp
 
 
     def get(self, request):
         acc = self.request.booking_account
-        self.context['balance'] = acc.get_balance()
+        balance = acc.get_balance()
+        self.context['balance'] = balance
         self.context['account_id'] = acc.id
+
+        domain = get_current_domain()
+        protocol = 'https' if self.request.is_secure() else 'http'
+        paypal_dict = {
+            "business": settings.PAYPAL_RECEIVER_EMAIL,
+            "amount": str(balance),
+            "item_name": "booking",
+            "invoice": "%s-%s-%s" % (acc.id, balance,
+                                     datetime.now()), # We don't need this, but must be unique
+            "notify_url":  "%s://%s%s" % (protocol, domain, reverse('paypal-ipn')),
+            "return_url": "%s://%s%s" % (protocol, domain, reverse('cciw.bookings.views.pay_done')),
+            "cancel_return": "%s://%s%s" % (protocol, domain, reverse('cciw.bookings.views.pay_cancelled')),
+            "custom": "account:%s;" % str(acc.id),
+            "currency_code": "GBP",
+            }
+
+        # Create the instance.
+        form = PayPalPaymentsForm(initial=paypal_dict)
+        self.context['form'] = form
+
         return super(BookingPay, self).get(request)
 
 
+class BookingPayDone(DefaultMetaData, TemplateView):
+    metadata_title = "Booking - payment complete"
+    template_name = "cciw/bookings/pay_done.html"
+
+
+class BookingPayCancelled(DefaultMetaData, TemplateView):
+    metadata_title = "Booking - payment cancelled"
+    template_name = "cciw/bookings/pay_cancelled.html"
+
+
 index = BookingIndex.as_view()
 start = BookingStart.as_view()
 email_sent = BookingEmailSent.as_view()
 edit_place = booking_account_required(BookingEditPlace.as_view())
 list_bookings = booking_account_required(BookingListBookings.as_view())
 pay = booking_account_required(BookingPay.as_view())
+pay_done = BookingPayDone.as_view()
+pay_cancelled = BookingPayCancelled.as_view()
+
     'securedownload',
     'autocomplete',
     'djiki',
+    'paypal.standard.ipn',
 )
 
 if not (LIVEBOX and WEBSERVER_RUNNING):
 
 DEFAULT_CONTENT_TYPE = "text/html"
 
+
+## PayPal ##
+
+PAYPAL_RECEIVER_EMAIL = "paypal@cciw.co.uk"
     url('^autocomplete/(\w+)/$', autocomplete, name='autocomplete'),
     (r'^wiki/$', RedirectView.as_view(url=u'/wiki/Index')),
     (r'^wiki/', include('djiki.urls')),
-
+    (r'^paypal/ipn/', include('paypal.standard.ipn.urls')),
 )
 
 if settings.DEVBOX:
 xlrd==0.7.1
 creole>=1.2
 dateutil=2.0
-
+-e git+https://github.com/dcramer/django-paypal#egg=paypal

templates/cciw/bookings/pay_cancelled.html

+{% extends 'cciw/standard.html' %}
+{% load url from future %}
+{% block content %}
+<h3>Payment cancelled</h3>
+
+<p>You cancelled your payment. Please ensure you
+<a href="{% url 'cciw.bookings.views.pay' %}">pay</a> within 24 hours, or your
+booking will expire and the place can be taken by someone else.
+</p>
+
+
+{% endblock %}

templates/cciw/bookings/pay_done.html

+{% extends 'cciw/standard.html' %}
+{% load url from future %}
+{% block content %}
+<h3>Payment complete!</h3>
+
+<p>Thank for you payment. Our records should be updated very shortly, and you
+  will receive a confirmation email.</p>
+
+
+{% endblock %}