Add /ipn/ API route to process PayPal IPNs

This commit is contained in:
Tanner Collin 2020-01-30 23:51:51 +00:00
parent c1e682478b
commit 8296295937
7 changed files with 353 additions and 6 deletions

View File

@ -1,4 +1,4 @@
from datetime import date from datetime import date, datetime
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.timezone import now from django.utils.timezone import now
@ -52,11 +52,21 @@ class Transaction(models.Model):
category = models.TextField(blank=True, null=True) category = models.TextField(blank=True, null=True)
account_type = models.TextField(blank=True, null=True) account_type = models.TextField(blank=True, null=True)
info_source = 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): class PayPalHint(models.Model):
account = models.CharField(unique=True, max_length=13) account = models.CharField(unique=True, max_length=13)
member_id = models.IntegerField() 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): class Card(models.Model):
user = models.ForeignKey(User, related_name='cards', blank=True, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(User, related_name='cards', blank=True, null=True, on_delete=models.SET_NULL)

View File

@ -40,6 +40,11 @@ class TransactionSerializer(serializers.ModelSerializer):
member_id = serializers.IntegerField() member_id = serializers.IntegerField()
member_name = serializers.SerializerMethodField() member_name = serializers.SerializerMethodField()
date = serializers.DateField() date = serializers.DateField()
report_type = serializers.ChoiceField([
'Unmatched Member',
'Unmatched Purchase',
'User Flagged',
])
class Meta: class Meta:
model = models.Transaction model = models.Transaction

View File

@ -5,8 +5,9 @@ django.setup()
from django.test import TestCase from django.test import TestCase
import datetime import datetime
from dateutil import relativedelta 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( testing_member, _ = models.Member.objects.get_or_create(
first_name='unittest', first_name='unittest',
@ -348,3 +349,28 @@ class TestTallyMembership(TestCase):
result = utils.tally_membership_months(member) result = utils.tally_membership_months(member)
self.assertEqual(result, False) 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)

View File

@ -1,5 +1,6 @@
import datetime import datetime
import io import io
import requests
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from dateutil import relativedelta from dateutil import relativedelta
from uuid import uuid4 from uuid import uuid4

View File

@ -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)

View File

@ -13,7 +13,9 @@ from fuzzywuzzy import fuzz, process
from collections import OrderedDict from collections import OrderedDict
import datetime import datetime
from . import models, serializers, utils import requests
from . import models, serializers, utils, utils_paypal
from .permissions import ( from .permissions import (
is_admin_director, is_admin_director,
AllowMetadata, AllowMetadata,
@ -218,9 +220,7 @@ class UserView(views.APIView):
return Response(serializer.data) return Response(serializer.data)
class DoorViewSet(Base, List): class DoorViewSet(viewsets.ViewSet, List):
serializer_class = serializers.CardSerializer
def list(self, request): def list(self, request):
cards = models.Card.objects.filter(active_status='card_active') cards = models.Card.objects.filter(active_status='card_active')
active_member_cards = {} active_member_cards = {}
@ -246,6 +246,17 @@ class DoorViewSet(Base, List):
return Response(200) 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): class RegistrationView(RegisterView):
serializer_class = serializers.RegistrationSerializer serializer_class = serializers.RegistrationSerializer

View File

@ -6,6 +6,7 @@ from rest_framework import routers
from .api import views from .api import views
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'ipn', views.IpnViewSet, basename='ipn')
router.register(r'door', views.DoorViewSet, basename='door') router.register(r'door', views.DoorViewSet, basename='door')
router.register(r'cards', views.CardViewSet, basename='card') router.register(r'cards', views.CardViewSet, basename='card')
router.register(r'search', views.SearchViewSet, basename='search') router.register(r'search', views.SearchViewSet, basename='search')