Add /ipn/ API route to process PayPal IPNs
This commit is contained in:
parent
c1e682478b
commit
8296295937
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
293
apiserver/apiserver/api/utils_paypal.py
Normal file
293
apiserver/apiserver/api/utils_paypal.py
Normal 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)
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
Loading…
Reference in New Issue
Block a user