From 8296295937faedb8b16fc4382a47ad83d334cb37 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Thu, 30 Jan 2020 23:51:51 +0000 Subject: [PATCH] Add /ipn/ API route to process PayPal IPNs --- apiserver/apiserver/api/models.py | 12 +- apiserver/apiserver/api/serializers.py | 5 + apiserver/apiserver/api/tests.py | 28 ++- apiserver/apiserver/api/utils.py | 1 + apiserver/apiserver/api/utils_paypal.py | 293 ++++++++++++++++++++++++ apiserver/apiserver/api/views.py | 19 +- apiserver/apiserver/urls.py | 1 + 7 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 apiserver/apiserver/api/utils_paypal.py diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py index 8352fe1..8010ec8 100644 --- a/apiserver/apiserver/api/models.py +++ b/apiserver/apiserver/api/models.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from django.db import models from django.contrib.auth.models import User from django.utils.timezone import now @@ -52,11 +52,21 @@ class Transaction(models.Model): category = models.TextField(blank=True, null=True) account_type = models.TextField(blank=True, null=True) info_source = models.TextField(blank=True, null=True) + paypal_txn_id = models.CharField(max_length=17, unique=True, blank=True, null=True) + paypal_payer_id = models.CharField(max_length=13, blank=True, null=True) + + report_type = models.TextField(blank=True, null=True) + report_memo = models.TextField(blank=True, null=True) class PayPalHint(models.Model): account = models.CharField(unique=True, max_length=13) member_id = models.IntegerField() +class IPN(models.Model): + datetime = models.DateTimeField(auto_now_add=True) + data = models.TextField() + status = models.CharField(max_length=32) + class Card(models.Model): user = models.ForeignKey(User, related_name='cards', blank=True, null=True, on_delete=models.SET_NULL) diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index 5b44b31..931de16 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -40,6 +40,11 @@ class TransactionSerializer(serializers.ModelSerializer): member_id = serializers.IntegerField() member_name = serializers.SerializerMethodField() date = serializers.DateField() + report_type = serializers.ChoiceField([ + 'Unmatched Member', + 'Unmatched Purchase', + 'User Flagged', + ]) class Meta: model = models.Transaction diff --git a/apiserver/apiserver/api/tests.py b/apiserver/apiserver/api/tests.py index 6d21ef5..76ad405 100644 --- a/apiserver/apiserver/api/tests.py +++ b/apiserver/apiserver/api/tests.py @@ -5,8 +5,9 @@ django.setup() from django.test import TestCase import datetime from dateutil import relativedelta +from rest_framework.exceptions import ValidationError -from apiserver.api import utils, models +from apiserver.api import utils, utils_paypal, models testing_member, _ = models.Member.objects.get_or_create( first_name='unittest', @@ -348,3 +349,28 @@ class TestTallyMembership(TestCase): result = utils.tally_membership_months(member) self.assertEqual(result, False) + +class TestParsePaypalDate(TestCase): + def test_parse(self): + string = '20:12:59 Jan 13, 2009 PST' + + result = utils_paypal.parse_paypal_date(string) + + self.assertEqual(str(result), '2009-01-14 04:12:59+00:00') + + def test_parse_dst(self): + string = '20:12:59 Jul 13, 2009 PDT' + + result = utils_paypal.parse_paypal_date(string) + + self.assertEqual(str(result), '2009-07-14 03:12:59+00:00') + + def test_parse_bad_tz(self): + string = '20:12:59 Jul 13, 2009 QOT' + + self.assertRaises(ValidationError, utils_paypal.parse_paypal_date, string) + + def test_parse_bad_string(self): + string = 'ave satanas' + + self.assertRaises(ValidationError, utils_paypal.parse_paypal_date, string) diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py index 095cff9..17682f0 100644 --- a/apiserver/apiserver/api/utils.py +++ b/apiserver/apiserver/api/utils.py @@ -1,5 +1,6 @@ import datetime import io +import requests from rest_framework.exceptions import ValidationError from dateutil import relativedelta from uuid import uuid4 diff --git a/apiserver/apiserver/api/utils_paypal.py b/apiserver/apiserver/api/utils_paypal.py new file mode 100644 index 0000000..54368e8 --- /dev/null +++ b/apiserver/apiserver/api/utils_paypal.py @@ -0,0 +1,293 @@ +import datetime +import json +import requests +from rest_framework.exceptions import ValidationError +from uuid import uuid4 + +from django.db.models import Sum +from django.utils import timezone + +from . import models, serializers, utils + +SANDBOX = True +if SANDBOX: + VERIFY_URL = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr' + OUR_EMAIL = 'seller@paypalsandbox.com' + OUR_CURRENCY = 'USD' +else: + VERIFY_URL = 'https://ipnpb.paypal.com/cgi-bin/webscr' + OUR_EMAIL = 'dunno' + OUR_CURRENCY = 'CAD' + +def parse_paypal_date(string): + ''' + Convert paypal date string into python datetime. Paypal's a bunch of idiots. + Their API returns dates in some custom format, so we have to parse it. + + Stolen from: + https://github.com/spookylukey/django-paypal/blob/master/paypal/standard/forms.py + + Return the UTC python datetime. + ''' + MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', + 'Sep', 'Oct', 'Nov', 'Dec', + ] + + value = string.strip() + try: + time_part, month_part, day_part, year_part, zone_part = value.split() + month_part = month_part.strip('.') + day_part = day_part.strip(',') + month = MONTHS.index(month_part) + 1 + day = int(day_part) + year = int(year_part) + hour, minute, second = map(int, time_part.split(':')) + dt = datetime.datetime(year, month, day, hour, minute, second) + except ValueError as e: + raise ValidationError('Invalid date format {} {}'.format( + value, str(e) + )) + + if zone_part in ['PDT', 'PST']: + # PST/PDT is 'US/Pacific' and ignored, localize only cares about date + dt = timezone.pytz.timezone('US/Pacific').localize(dt) + dt = dt.astimezone(timezone.pytz.UTC) + else: + raise ValidationError('Bad timezone: ' + zone_part) + return dt + +def record_ipn(data): + ''' + Record each individual IPN (even dupes) for logging and debugging + ''' + return models.IPN.objects.create( + data=data.urlencode(), + status='New', + ) + +def update_ipn(ipn, status): + ipn.status = status + ipn.save() + +def verify_paypal_ipn(data): + params = data.copy() + params['cmd'] = '_notify-validate' + headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'user-agent': 'spaceport', + } + + try: + r = requests.post(VERIFY_URL, params=params, headers=headers, timeout=2) + r.raise_for_status() + if r.text != 'VERIFIED': + return False + except BaseException as e: + print('Problem verifying IPN: {} - {}'.format(e.__class__.__name__, str(e))) + return False + + return True + +def build_tx(data): + amount = float(data['mc_gross']) + return dict( + account_type='PayPal', + amount=amount, + date=parse_paypal_date(data['payment_date']), + info_source='PayPal IPN', + payment_method=data['payment_type'], + paypal_payer_id=data['payer_id'], + paypal_txn_id=data['txn_id'], + reference_number=data['txn_id'], + ) + +def create_unmatched_member_tx(data): + transactions = models.Transaction.objects + + report_memo = 'Cant link sender name: {} {}, email: {}, note: {}'.format( + data['first_name'], + data['last_name'], + data['payer_email'], + data['custom'], + ) + + return transactions.create( + **build_tx(data), + report_memo=report_memo, + report_type='Unmatched Member', + ) + +def create_member_dues_tx(data, member, num_months): + transactions = models.Transaction.objects + + # new member 3 for 2 will have to be manual anyway + if num_months == 11: + num_months = 12 + deal = '12 for 11, ' + else: + deal = '' + + user = getattr(member, 'user', None) + memo = '{}{} {} - Protospace Membership, {}'.format( + deal, + data['first_name'], + data['last_name'], + data['payer_email'], + ) + + tx = transactions.create( + **build_tx(data), + member_id=member.id, + memo=memo, + number_of_membership_months=num_months, + user=user, + ) + utils.tally_membership_months(member) + return tx + +def create_unmatched_purchase_tx(data, member): + transactions = models.Transaction.objects + + user = getattr(member, 'user', None) + report_memo = 'Unknown payment reason: {} {}, email: {}, note: {}'.format( + data['first_name'], + data['last_name'], + data['payer_email'], + data['custom'], + ) + + return transactions.create( + **build_tx(data), + member_id=member.id, + report_memo=report_memo, + report_type='Unmatched Purchase', + user=user, + ) + +def create_member_training_tx(data, member, training): + transactions = models.Transaction.objects + + user = getattr(member, 'user', None) + memo = '{} {} - {} Course, email: {}, session: {}, training: {}'.format( + data['first_name'], + data['last_name'], + training.session.course.name, + data['payer_email'], + str(training.session.id), + str(training.id), + ) + + return transactions.create( + **build_tx(data), + member_id=member.id, + memo=memo, + user=user, + ) + +def check_training(data, member, training_id, amount): + trainings = models.Training.objects + + if not trainings.filter(id=training_id).exists(): + return False + + training = trainings.get(id=training_id) + + if training.attendance_status != 'waiting for payment': + return False + + if not training.session: + return False + + if training.session.is_cancelled: + return False + + if training.session.cost != amount: + return False + + if not training.user: + return False + + if training.user.member != member: + return False + + training.attendance_status = 'confirmed' + training.save() + + print('Amount valid for training cost, id:', training.id) + return create_member_training_tx(data, member, training) + + +def process_paypal_ipn(data): + ''' + Receive IPN from PayPal, then verify it. If it's good, try to associate it + with a member. If the value is a multiple of member dues, credit that many + months of membership. Ignore if payment incomplete or duplicate IPN. + + Blocks the IPN POST response, so keep it quick. + ''' + ipn = record_ipn(data) + + if verify_paypal_ipn(data): + print('IPN verified') + else: + update_ipn(ipn, 'Verification Failed') + return False + + amount = float(data['mc_gross']) + + if data['payment_status'] != 'Completed': + print('Payment not yet completed, ignoring') + update_ipn(ipn, 'Payment Incomplete') + return False + + if data['receiver_email'] != OUR_EMAIL: + print('Payment not for us, ignoring') + update_ipn(ipn, 'Invalid Receiver') + return False + + if data['mc_currency'] != OUR_CURRENCY: + print('Payment currency invalid, ignoring') + update_ipn(ipn, 'Invalid Currency') + return False + + transactions = models.Transaction.objects + members = models.Member.objects + hints = models.PayPalHint.objects + + if transactions.filter(paypal_txn_id=data['txn_id']).exists(): + print('Duplicate transaction, ignoring') + update_ipn(ipn, 'Duplicate') + return False + + if not hints.filter(account=data['payer_id']).exists(): + print('Unable to associate with member, reporting') + update_ipn(ipn, 'Accepted, Unmatched Member') + return create_unmatched_member_tx(data) + + member_id = hints.get(account=data['payer_id']).member_id + member = members.get(id=member_id) + monthly_fees = member.monthly_fees + + if amount.is_integer() and amount % monthly_fees == 0: + num_months = int(amount // monthly_fees) + else: + num_months = 0 + + if num_months: + print('Amount valid for membership dues, adding months:', num_months) + update_ipn(ipn, 'Accepted, Member Dues') + return create_member_dues_tx(data, member, num_months) + + try: + custom_json = json.loads(data['custom']) + except (KeyError, ValueError): + custom_json = False + + if custom_json and 'training' in custom_json: + tx = check_training(data, member, custom_json['training'], amount) + if tx: return tx + + print('Unable to find a reason for payment, reporting') + update_ipn(ipn, 'Accepted, Unmatched Purchase') + return create_unmatched_purchase_tx(data, member) diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index 3d28ab5..d054630 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -13,7 +13,9 @@ from fuzzywuzzy import fuzz, process from collections import OrderedDict import datetime -from . import models, serializers, utils +import requests + +from . import models, serializers, utils, utils_paypal from .permissions import ( is_admin_director, AllowMetadata, @@ -218,9 +220,7 @@ class UserView(views.APIView): return Response(serializer.data) -class DoorViewSet(Base, List): - serializer_class = serializers.CardSerializer - +class DoorViewSet(viewsets.ViewSet, List): def list(self, request): cards = models.Card.objects.filter(active_status='card_active') active_member_cards = {} @@ -246,6 +246,17 @@ class DoorViewSet(Base, List): return Response(200) + +class IpnViewSet(viewsets.ViewSet, Create): + def create(self, request): + try: + utils_paypal.process_paypal_ipn(request.data) + except BaseException as e: + print('Problem processing IPN: {} - {}'.format(e.__class__.__name__, str(e))) + finally: + return Response(200) + + class RegistrationView(RegisterView): serializer_class = serializers.RegistrationSerializer diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index 9ab0d5d..8279fb3 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -6,6 +6,7 @@ from rest_framework import routers from .api import views router = routers.DefaultRouter() +router.register(r'ipn', views.IpnViewSet, basename='ipn') router.register(r'door', views.DoorViewSet, basename='door') router.register(r'cards', views.CardViewSet, basename='card') router.register(r'search', views.SearchViewSet, basename='search')