You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
403 lines
12 KiB
403 lines
12 KiB
import logging |
|
logger = logging.getLogger(__name__) |
|
|
|
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 django.utils.timezone import now |
|
|
|
from . import models, serializers, utils |
|
from .. import settings |
|
|
|
SANDBOX = False |
|
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 = 'paypal@protospace.ca' |
|
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', |
|
] |
|
|
|
if not string: return now() |
|
|
|
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): |
|
if settings.DEBUG: |
|
return True |
|
|
|
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=4) |
|
r.raise_for_status() |
|
logger.info('Result: ' + r.text) |
|
if r.text == 'VERIFIED': |
|
return True |
|
except BaseException as e: |
|
logger.error('IPN verify - {} - {}'.format(e.__class__.__name__, str(e))) |
|
|
|
logger.info('IPN - verification failed, retrying...') |
|
|
|
try: |
|
r = requests.post(VERIFY_URL, params=params, headers=headers, timeout=4) |
|
r.raise_for_status() |
|
logger.info('Result: ' + r.text) |
|
if r.text == 'VERIFIED': |
|
return True |
|
except BaseException as e: |
|
logger.error('IPN verify - {} - {}'.format(e.__class__.__name__, str(e))) |
|
|
|
utils.alert_tanner('IPN failed to verify:\n\n' + str(data.dict())) |
|
|
|
return False |
|
|
|
def build_tx(data): |
|
amount = float(data.get('mc_gross', 0)) |
|
return dict( |
|
account_type='PayPal', |
|
amount=amount, |
|
date=parse_paypal_date(data.get('payment_date', '')), |
|
info_source='PayPal IPN', |
|
payment_method=data.get('payment_type', 'unknown'), |
|
paypal_payer_id=data.get('payer_id', 'unknown'), |
|
paypal_txn_id=data.get('txn_id', 'unknown'), |
|
paypal_txn_type=data.get('txn_type', 'unknown'), |
|
reference_number=data.get('txn_id', 'unknown'), |
|
) |
|
|
|
def create_unmatched_member_tx(data): |
|
transactions = models.Transaction.objects |
|
|
|
report_memo = 'Cant link sender name, {} {}, email: {}, note: {} - {}'.format( |
|
data.get('first_name', 'unknown'), |
|
data.get('last_name', 'unknown'), |
|
data.get('payer_email', 'unknown'), |
|
data.get('custom', 'none'), |
|
data.get('memo', 'none'), |
|
) |
|
|
|
tx = transactions.create( |
|
**build_tx(data), |
|
report_memo=report_memo, |
|
report_type='Unmatched Member', |
|
) |
|
|
|
utils.log_transaction(tx) |
|
return tx |
|
|
|
def create_member_dues_tx(data, member, num_months, deal): |
|
transactions = models.Transaction.objects |
|
|
|
# new member 3 for 2 will have to be manual anyway |
|
if deal == 12 and num_months == 11: |
|
num_months = 12 |
|
deal_str = '12 for 11, ' |
|
elif deal == 3 and num_months == 2: |
|
num_months = 3 |
|
deal_str = '3 for 2, ' |
|
elif num_months == 11: # handle pre-Spaceport yearly subs |
|
num_months = 12 |
|
deal_str = '12 for 11 (legacy), ' |
|
else: |
|
deal_str = '' |
|
|
|
user = getattr(member, 'user', None) |
|
memo = '{}{} {} - Protospace Membership, {}'.format( |
|
deal_str, |
|
data.get('first_name', 'unknown'), |
|
data.get('last_name', 'unknown'), |
|
data.get('payer_email', 'unknown'), |
|
) |
|
|
|
tx = transactions.create( |
|
**build_tx(data), |
|
memo=memo, |
|
category='Membership', |
|
number_of_membership_months=num_months, |
|
user=user, |
|
) |
|
utils.tally_membership_months(member) |
|
utils.log_transaction(tx) |
|
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.get('first_name', 'unknown'), |
|
data.get('last_name', 'unknown'), |
|
data.get('payer_email', 'unknown'), |
|
data.get('custom', 'none'), |
|
data.get('memo', 'none'), |
|
) |
|
|
|
tx = transactions.create( |
|
**build_tx(data), |
|
report_memo=report_memo, |
|
report_type='Unmatched Purchase', |
|
user=user, |
|
) |
|
|
|
utils.log_transaction(tx) |
|
return tx |
|
|
|
def create_member_training_tx(data, member, training): |
|
transactions = models.Transaction.objects |
|
|
|
user = getattr(member, 'user', None) |
|
memo = '{} {} - {} Course, email: {}, session: {}, training: {}'.format( |
|
data.get('first_name', 'unknown'), |
|
data.get('last_name', 'unknown'), |
|
training.session.course.name, |
|
data.get('payer_email', 'unknown'), |
|
str(training.session.id), |
|
str(training.id), |
|
) |
|
|
|
tx = transactions.create( |
|
**build_tx(data), |
|
category='OnAcct', |
|
memo=memo, |
|
user=user, |
|
) |
|
|
|
utils.log_transaction(tx) |
|
return tx |
|
|
|
def check_training(data, 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 |
|
|
|
member = training.user.member |
|
|
|
if training.attendance_status == 'Waiting for payment': |
|
training.attendance_status = 'Confirmed' |
|
training.paid_date = datetime.date.today() |
|
training.save() |
|
|
|
logger.info('IPN - Amount valid for training cost, id: ' + str(training.id)) |
|
return create_member_training_tx(data, member, training) |
|
|
|
def create_category_tx(data, member, custom_json, amount): |
|
transactions = models.Transaction.objects |
|
|
|
user = getattr(member, 'user', None) |
|
category = custom_json['category'] |
|
|
|
if category == 'Exchange': |
|
protocoin = amount |
|
note = '{} Protocoin Purchase'.format(amount) |
|
else: |
|
protocoin = 0 |
|
note = custom_json.get('memo', 'none') |
|
|
|
memo = '{} {} - {}, email: {}, note: {}'.format( |
|
data.get('first_name', 'unknown'), |
|
data.get('last_name', 'unknown'), |
|
category, |
|
data.get('payer_email', 'unknown'), |
|
note, |
|
) |
|
|
|
tx = transactions.create( |
|
**build_tx(data), |
|
category=category, |
|
memo=memo, |
|
user=user, |
|
protocoin=protocoin, |
|
) |
|
|
|
utils.log_transaction(tx) |
|
return tx |
|
|
|
|
|
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): |
|
logger.info('IPN - verified') |
|
else: |
|
logger.error('IPN - verification failed') |
|
update_ipn(ipn, 'Verification Failed') |
|
return False |
|
|
|
amount = float(data.get('mc_gross', '0')) |
|
|
|
if data.get('payment_status', 'unknown') != 'Completed': |
|
logger.info('IPN - Payment not yet completed, ignoring') |
|
update_ipn(ipn, 'Payment Incomplete') |
|
return False |
|
|
|
if data.get('receiver_email', 'unknown') != OUR_EMAIL: |
|
logger.info('IPN - Payment not for us, ignoring') |
|
update_ipn(ipn, 'Invalid Receiver') |
|
return False |
|
|
|
if data.get('mc_currency', 'unknown') != OUR_CURRENCY: |
|
logger.info('IPN - Payment currency invalid, ignoring') |
|
update_ipn(ipn, 'Invalid Currency') |
|
return False |
|
|
|
transactions = models.Transaction.objects |
|
members = models.Member.objects |
|
hints = models.PayPalHint.objects |
|
|
|
if 'txn_id' not in data: |
|
logger.info('IPN - Missing transaction ID, ignoring') |
|
update_ipn(ipn, 'Missing ID') |
|
return False |
|
|
|
# TODO: index txn_id? |
|
if transactions.filter(paypal_txn_id=data['txn_id']).exists(): |
|
logger.info('IPN - Duplicate transaction, ignoring') |
|
update_ipn(ipn, 'Duplicate') |
|
return False |
|
|
|
try: |
|
custom_json = json.loads(data.get('custom', '').replace('`', '"')) |
|
except (KeyError, ValueError): |
|
custom_json = {} |
|
|
|
if 'training' in custom_json: |
|
tx = check_training(data, custom_json['training'], amount) |
|
if tx: |
|
logger.info('IPN - Training matched, adding hint and returning') |
|
update_ipn(ipn, 'Accepted, training') |
|
hints.update_or_create( |
|
account=data.get('payer_id', 'unknown'), |
|
defaults=dict(user=tx.user), |
|
) |
|
return tx |
|
|
|
user = False |
|
|
|
try: |
|
user = hints.get(account=data['payer_id']).user |
|
except models.PayPalHint.DoesNotExist: |
|
logger.info('IPN - No PayPalHint found for %s', data['payer_id']) |
|
|
|
if not user and 'member' in custom_json: |
|
member_id = custom_json['member'] |
|
try: |
|
user = members.get(id=member_id).user |
|
except models.Member.DoesNotExist: |
|
pass |
|
|
|
if not user: |
|
logger.info('IPN - Unable to associate with member, reporting') |
|
update_ipn(ipn, 'Accepted, Unmatched Member') |
|
return create_unmatched_member_tx(data) |
|
|
|
member = user.member |
|
|
|
hints.update_or_create( |
|
account=data.get('payer_id', 'unknown'), |
|
defaults=dict(user=user), |
|
) |
|
|
|
if custom_json.get('category', False) in ['Snacks', 'OnAcct', 'Donation', 'Consumables', 'Purchases', 'Exchange']: |
|
logger.info('IPN - Category matched') |
|
update_ipn(ipn, 'Accepted, category') |
|
return create_category_tx(data, member, custom_json, amount) |
|
|
|
monthly_fees = member.monthly_fees |
|
|
|
if amount.is_integer() and monthly_fees and amount % monthly_fees == 0: |
|
num_months = int(amount // monthly_fees) |
|
else: |
|
num_months = 0 |
|
|
|
if num_months: |
|
logger.info('IPN - Amount valid for membership dues, adding months') |
|
update_ipn(ipn, 'Accepted, Member Dues') |
|
deal = custom_json.get('deal', False) |
|
return create_member_dues_tx(data, member, num_months, deal) |
|
|
|
logger.info('IPN - Unable to find a reason for payment, reporting') |
|
update_ipn(ipn, 'Accepted, Unmatched Purchase') |
|
return create_unmatched_purchase_tx(data, member)
|
|
|