334 строки
12 KiB
Python
334 строки
12 KiB
Python
from decimal import Decimal
|
|
import random
|
|
import re
|
|
|
|
from django import http
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
import commonware.log
|
|
from django_statsd.clients import statsd
|
|
import phpserialize as php
|
|
import requests
|
|
import waffle
|
|
|
|
import amo
|
|
from amo.decorators import post_required, write
|
|
from lib.pay_server import client
|
|
from paypal import paypal_log_cef
|
|
from stats.db import StatsDictField
|
|
from stats.models import Contribution, ContributionError
|
|
|
|
paypal_log = commonware.log.getLogger('z.paypal')
|
|
|
|
|
|
@write
|
|
@csrf_exempt
|
|
@post_required
|
|
def paypal(request):
|
|
"""
|
|
Handle PayPal IPN post-back for contribution transactions.
|
|
|
|
IPN will retry periodically until it gets success (status=200). Any
|
|
db errors or replication lag will result in an exception and http
|
|
status of 500, which is good so PayPal will try again later.
|
|
|
|
PayPal IPN variables available at:
|
|
https://cms.paypal.com/us/cgi-bin/?cmd=_render-content
|
|
&content_ID=developer/e_howto_html_IPNandPDTVariables
|
|
"""
|
|
try:
|
|
if waffle.flag_is_active(request, 'solitude-payments'):
|
|
return ipn(request)
|
|
return _paypal(request)
|
|
except Exception, e:
|
|
paypal_log.error('%s\n%s' % (e, request), exc_info=True)
|
|
if settings.IN_TEST_SUITE:
|
|
raise
|
|
return http.HttpResponseServerError('Unknown error.')
|
|
|
|
|
|
def ipn(request):
|
|
result = client.post_ipn(data={'data': request.read()})
|
|
paypal_log.info('Solitude IPN returned: %s' % result['status'])
|
|
|
|
# PayPal could not verify this result.
|
|
if result['status'] in ['IGNORED', 'ERROR']:
|
|
paypal_log.info('Solitude IPN ignored: %s' % result['status'])
|
|
return http.HttpResponse('Ignored')
|
|
|
|
paypal_log.info('Solitude IPN processed: %s,' % result['action'])
|
|
|
|
# Process the payment.
|
|
if result['action'] == 'PAYMENT':
|
|
return paypal_completed(request, result['uuid'])
|
|
|
|
elif result['action'] == 'REFUND':
|
|
return paypal_refunded(request, result['uuid'],
|
|
amount=result['amount'])
|
|
|
|
elif result['action'] == 'REVERSAL':
|
|
return paypal_reversal(request, result['uuid'],
|
|
amount=result['amount'])
|
|
|
|
else:
|
|
raise NotImplementedError
|
|
|
|
|
|
# TODO(solitude): alot of this can be removed or refactored.
|
|
def _log_error_with_data(msg, post):
|
|
"""Log a message along with some of the POST info from PayPal."""
|
|
|
|
id = random.randint(0, 99999999)
|
|
msg = "[%s] %s (dumping data)" % (id, msg)
|
|
|
|
paypal_log.error(msg)
|
|
|
|
logme = {'txn_id': post.get('txn_id'),
|
|
'txn_type': post.get('txn_type'),
|
|
'payer_email': post.get('payer_email'),
|
|
'receiver_email': post.get('receiver_email'),
|
|
'payment_status': post.get('payment_status'),
|
|
'payment_type': post.get('payment_type'),
|
|
'mc_gross': post.get('mc_gross'),
|
|
'item_number': post.get('item_number'),
|
|
}
|
|
|
|
paypal_log.error("[%s] PayPal Data: %s" % (id, logme))
|
|
|
|
|
|
def _log_unmatched(post):
|
|
key = "%s%s:%s" % (settings.CACHE_PREFIX, 'contrib',
|
|
post['item_number'])
|
|
count = cache.get(key, 0) + 1
|
|
|
|
paypal_log.warning('Contribution not found: %s, #%s, %s'
|
|
% (post['item_number'], count,
|
|
post.get('txn_id', '')))
|
|
|
|
if count > 10:
|
|
msg = ("PayPal sent a transaction that we don't know "
|
|
"about and we're giving up on it.")
|
|
_log_error_with_data(msg, post)
|
|
cache.delete(key)
|
|
return http.HttpResponse('Transaction not found; skipping.')
|
|
|
|
cache.set(key, count, 1209600) # This is 2 weeks.
|
|
return http.HttpResponseServerError('Contribution not found')
|
|
|
|
|
|
number = re.compile('transaction\[(?P<number>\d+)\]\.(?P<name>\w+)')
|
|
currency = re.compile('(?P<currency>\w+) (?P<amount>[\d.,]+)')
|
|
|
|
|
|
def _parse(post):
|
|
"""
|
|
List of (old, new) codes so we can transpose the data for
|
|
embedded payments.
|
|
"""
|
|
for old, new in [('payment_status', 'status'),
|
|
('item_number', 'tracking_id'),
|
|
('txn_id', 'tracking_id'),
|
|
('payer_email', 'sender_email')]:
|
|
if old not in post and new in post:
|
|
post[old] = post[new]
|
|
|
|
transactions = {}
|
|
for k, v in post.items():
|
|
match = number.match(k)
|
|
if match:
|
|
data = match.groupdict()
|
|
transactions.setdefault(data['number'], {})
|
|
transactions[data['number']][data['name']] = v
|
|
|
|
return post, transactions
|
|
|
|
|
|
def _parse_currency(value):
|
|
"""Parse USD 10.00 into a dictionary of currency and amount as Decimal."""
|
|
# If you are using solitude, it's a dictionary. However it returns
|
|
# amount as a string, not a decimal.
|
|
if not isinstance(value, dict):
|
|
value = currency.match(value).groupdict()
|
|
value['amount'] = Decimal(value['amount'])
|
|
return value
|
|
|
|
|
|
def _paypal(request):
|
|
# Must be this way around.
|
|
post, raw = request.POST.copy(), request.read()
|
|
paypal_log.info('IPN received: %s' % raw)
|
|
|
|
# Check that the request is valid and coming from PayPal.
|
|
# The order of the params has to match the original request.
|
|
data = u'cmd=_notify-validate&' + raw
|
|
with statsd.timer('paypal.validate-ipn'):
|
|
paypal_response = requests.post(settings.PAYPAL_CGI_URL, data,
|
|
verify=True,
|
|
cert=settings.PAYPAL_CERT)
|
|
|
|
post, transactions = _parse(post)
|
|
|
|
# If paypal doesn't like us, fail.
|
|
if paypal_response.text != 'VERIFIED':
|
|
msg = ("Expecting 'VERIFIED' from PayPal, got '%s'. "
|
|
"Failing." % paypal_response)
|
|
_log_error_with_data(msg, post)
|
|
return http.HttpResponseForbidden('Invalid confirmation')
|
|
|
|
payment_status = post.get('payment_status', '').lower()
|
|
if payment_status != 'completed':
|
|
paypal_log.info('Payment status not completed: %s, %s'
|
|
% (post.get('txn_id', ''), payment_status))
|
|
return http.HttpResponse('Ignoring %s' % post.get('txn_id', ''))
|
|
|
|
# There could be multiple transactions on the IPN. This will deal
|
|
# with them appropriately or cope if we don't know how to deal with
|
|
# any of them.
|
|
methods = {'refunded': paypal_refunded,
|
|
'completed': paypal_completed,
|
|
'reversal': paypal_reversal}
|
|
result = None
|
|
called = False
|
|
# Ensure that we process 0, then 1 etc.
|
|
for (k, v) in sorted(transactions.items()):
|
|
status = v.get('status', '').lower()
|
|
if status not in methods:
|
|
paypal_log.info('Unknown status: %s' % status)
|
|
continue
|
|
result = methods[status](request, post.get('txn_id'),
|
|
post, v.get('amount'))
|
|
called = True
|
|
# Because of chained payments a refund is more than one transaction.
|
|
# But from our point of view, it's actually only one transaction and
|
|
# we can safely ignore the rest.
|
|
if result.content == 'Success!' and status == 'refunded':
|
|
break
|
|
|
|
if not called:
|
|
# Whilst the payment status was completed, it contained
|
|
# no transactions with status, which means we don't know
|
|
# how to process it. Hence it's being ignored.
|
|
paypal_log.info('No methods to call on: %s' % post.get('txn_id', ''))
|
|
return http.HttpResponse('Ignoring %s' % post.get('txn_id', ''))
|
|
|
|
return result
|
|
|
|
|
|
def paypal_refunded(request, transaction_id, serialize=None, amount=None):
|
|
try:
|
|
original = Contribution.objects.get(transaction_id=transaction_id)
|
|
except Contribution.DoesNotExist:
|
|
paypal_log.info('Ignoring transaction: %s' % transaction_id)
|
|
return http.HttpResponse('Transaction not found; skipping.')
|
|
|
|
# If the contribution has a related contribution we've processed it.
|
|
try:
|
|
original = Contribution.objects.get(related=original)
|
|
paypal_log.info('Related contribution, state: %s, pk: %s' %
|
|
(original.related.type, original.related.pk))
|
|
return http.HttpResponse('Transaction already processed')
|
|
except Contribution.DoesNotExist:
|
|
pass
|
|
|
|
original.handle_chargeback('refund')
|
|
paypal_log.info('Refund IPN received: %s' % transaction_id)
|
|
price_currency = _parse_currency(amount)
|
|
amount = price_currency['amount']
|
|
currency = price_currency['currency']
|
|
|
|
# Contribution with negative amount for refunds.
|
|
Contribution.objects.create(
|
|
addon=original.addon, related=original,
|
|
user=original.user, type=amo.CONTRIB_REFUND,
|
|
amount=-amount, currency=currency,
|
|
price_tier=original.price_tier,
|
|
post_data=php.serialize(serialize)
|
|
)
|
|
paypal_log.info('Refund successfully processed')
|
|
|
|
paypal_log_cef(request, original.addon, transaction_id,
|
|
'Refund', 'REFUND',
|
|
'A paypal refund was processed')
|
|
|
|
return http.HttpResponse('Success!')
|
|
|
|
|
|
def paypal_reversal(request, transaction_id, serialize=None, amount=None):
|
|
try:
|
|
original = Contribution.objects.get(transaction_id=transaction_id)
|
|
except Contribution.DoesNotExist:
|
|
paypal_log.info('Ignoring transaction: %s' % transaction_id)
|
|
return http.HttpResponse('Transaction not found; skipping.')
|
|
|
|
# If the contribution has a related contribution we've processed it.
|
|
try:
|
|
original = Contribution.objects.get(related=original)
|
|
paypal_log.info('Related contribution, state: %s, pk: %s' %
|
|
(original.related.type, original.related.pk))
|
|
return http.HttpResponse('Transaction already processed')
|
|
except Contribution.DoesNotExist:
|
|
pass
|
|
|
|
original.handle_chargeback('reversal')
|
|
paypal_log.info('Reversal IPN received: %s' % transaction_id)
|
|
amount = _parse_currency(amount)
|
|
refund = Contribution.objects.create(
|
|
addon=original.addon, related=original,
|
|
user=original.user, type=amo.CONTRIB_CHARGEBACK,
|
|
amount=-amount['amount'], currency=amount['currency'],
|
|
post_data=php.serialize(serialize)
|
|
)
|
|
refund.mail_chargeback()
|
|
|
|
paypal_log_cef(request, original.addon, transaction_id,
|
|
'Chargeback', 'CHARGEBACK',
|
|
'A paypal chargeback was processed')
|
|
|
|
return http.HttpResponse('Success!')
|
|
|
|
|
|
def paypal_completed(request, transaction_id, serialize=None, amount=None):
|
|
# Make sure transaction has not yet been processed.
|
|
if Contribution.objects.filter(transaction_id=transaction_id).exists():
|
|
paypal_log.info('Completed IPN already processed')
|
|
return http.HttpResponse('Transaction already processed')
|
|
|
|
# Note that when this completes the uuid is moved over to transaction_id.
|
|
try:
|
|
original = Contribution.objects.get(uuid=transaction_id)
|
|
except Contribution.DoesNotExist:
|
|
paypal_log.info('Ignoring transaction: %s' % transaction_id)
|
|
return http.HttpResponse('Transaction not found; skipping.')
|
|
|
|
paypal_log.info('Completed IPN received: %s' % transaction_id)
|
|
data = StatsDictField().to_python(php.serialize(serialize))
|
|
update = {'transaction_id': transaction_id,
|
|
'uuid': None, 'post_data': data}
|
|
|
|
if original.type == amo.CONTRIB_PENDING:
|
|
# This is a purchase that has failed to hit the completed page.
|
|
# But this ok, this IPN means that it all went through.
|
|
update['type'] = amo.CONTRIB_PURCHASE
|
|
# If they failed to hit the completed page, they also failed
|
|
# to get it logged properly. This will add the log in.
|
|
amo.log(amo.LOG.PURCHASE_ADDON, original.addon)
|
|
|
|
if amount:
|
|
update['amount'] = _parse_currency(amount)['amount']
|
|
|
|
original.update(**update)
|
|
# Send thankyou email.
|
|
try:
|
|
original.mail_thankyou(request)
|
|
except ContributionError as e:
|
|
# A failed thankyou email is not a show stopper, but is good to know.
|
|
paypal_log.error('Thankyou note email failed with error: %s' % e)
|
|
|
|
paypal_log_cef(request, original.addon, transaction_id,
|
|
'Purchase', 'PURCHASE',
|
|
'A user purchased or contributed to an addon')
|
|
paypal_log.info('Completed successfully processed')
|
|
return http.HttpResponse('Success!')
|