import contextlib import socket import urllib import urllib2 import urlparse import re from django.conf import settings from django.utils.http import urlencode import commonware.log from statsd import statsd from amo.helpers import absolutify from amo.urlresolvers import reverse class PaypalError(Exception): id = None class AuthError(PaypalError): pass errors = {'520003': AuthError} paypal_log = commonware.log.getLogger('z.paypal') def should_ignore_paypal(): """ Returns whether to skip PayPal communications for development purposes or not. """ return settings.DEBUG and 'sandbox' not in settings.PAYPAL_PERMISSIONS_URL def get_paykey(data): """ Gets a paykey from Paypal. Need to pass in the following in data: pattern: the reverse pattern to resolve email: who the money is going to (required) amount: the amount of money (required) ip: ip address of end user (required) uuid: contribution_uuid (required) memo: any nice message qs: anything you want to append to the complete or cancel(optional) """ complete = reverse(data['pattern'], args=[data['slug'], 'complete']) cancel = reverse(data['pattern'], args=[data['slug'], 'cancel']) qs = {'uuid': data['uuid']} if 'qs' in data: qs.update(data['qs']) uuid_qs = urllib.urlencode(qs) paypal_data = { 'actionType': 'PAY', 'requestEnvelope.errorLanguage': 'US', 'currencyCode': 'USD', 'cancelUrl': absolutify('%s?%s' % (cancel, uuid_qs)), 'returnUrl': absolutify('%s?%s' % (complete, uuid_qs)), 'receiverList.receiver(0).email': data['email'], 'receiverList.receiver(0).amount': data['amount'], 'receiverList.receiver(0).invoiceID': 'mozilla-%s' % data['uuid'], 'receiverList.receiver(0).primary': 'TRUE', 'receiverList.receiver(0).paymentType': 'DIGITALGOODS', 'trackingId': data['uuid']} if data.get('ipn', True): paypal_data['ipnNotificationUrl'] = absolutify(reverse('amo.paypal')) if data.get('memo'): paypal_data['memo'] = data['memo'] with statsd.timer('paypal.paykey.retrieval'): try: response = _call(settings.PAYPAL_PAY_URL + 'Pay', paypal_data, ip=data['ip']) except AuthError, error: paypal_log.error('Authentication error: %s' % error) raise return response['payKey'] def check_purchase(paykey): """ When a purchase is complete checks paypal that the purchase has gone through. """ with statsd.timer('paypal.payment.details'): try: response = _call(settings.PAYPAL_PAY_URL + 'PaymentDetails', {'payKey': paykey}) except PaypalError: paypal_log.error('Payment details error', exc_info=True) return False return response['status'] def refund(txnid): """ Refund a payment. Arguments: transaction id of payment to refund Returns: A list of dicts containing the refund info for each receiver of the original payment. """ OK_STATUSES = ['REFUNDED', 'REFUNDED_PENDING'] with statsd.timer('paypal.payment.refund'): try: response = _call(settings.PAYPAL_PAY_URL + 'Refund', {'transactionID': txnid}) except PaypalError: paypal_log.error('Refund error', exc_info=True) raise responses = [] for k in response: g = re.match('refundInfoList.refundInfo\((\d+)\).(.*)', k) if g: i = int(g.group(1)) subkey = g.group(2) while i >= len(responses): responses.append({}) responses[i][subkey] = response[k] for d in responses: status = '%s: %s' % (d['receiver.email'], d['refundStatus']) if d['refundStatus'] not in OK_STATUSES: raise PaypalError('Bad refund status for %s' % status) paypal_log.debug('Refund done for transaction %s, status: %s' % (txnid, status)) return responses def check_refund_permission(token): """ Asks PayPal whether the PayPal ID for this account has granted refund permission to us. """ # This is set in settings_test so we don't start calling PayPal # by accident. Explicitly set this in your tests. if not settings.PAYPAL_PERMISSIONS_URL: return False paypal_log.debug('Checking refund permission for token: %s..' % token[:10]) try: with statsd.timer('paypal.permissions.refund'): r = _call(settings.PAYPAL_PERMISSIONS_URL + 'GetPermissions', {'token': token}) except PaypalError, error: paypal_log.debug('Paypal returned error for token: %s.. error: %s' % (token[:10], error)) return False # in the future we may ask for other permissions so let's just # make sure REFUND is one of them. paypal_log.debug('Paypal returned permissions for token: %s.. perms: %s' % (token[:10], r)) return 'REFUND' in [v for (k, v) in r.iteritems() if k.startswith('scope')] def refund_permission_url(addon): """ Send permissions request to PayPal for refund privileges on this addon's paypal account. Returns URL on PayPal site to visit. """ # This is set in settings_test so we don't start calling PayPal # by accident. Explicitly set this in your tests. if not settings.PAYPAL_PERMISSIONS_URL: return '' paypal_log.debug('Getting refund permission URL for addon: %s' % addon.pk) with statsd.timer('paypal.permissions.url'): url = reverse('devhub.addons.acquire_refund_permission', args=[addon.slug]) try: r = _call(settings.PAYPAL_PERMISSIONS_URL + 'RequestPermissions', {'scope': 'REFUND', 'callback': absolutify(url)}) except PaypalError, e: paypal_log.debug('Error on refund permission URL addon: %s, %s' % (addon.pk, e)) if 'malformed' in str(e): # PayPal is very picky about where they redirect users to. # If you try and create a PayPal permissions URL on a # zamboni that has a non-standard port number or a # non-standard TLD, it will blow up with an error. We need # to be able to at least visit these pages and alter them # in dev, so this will give you a broken token that doesn't # work, but at least the page will function. r = {'token': 'wont-work-paypal-doesnt-like-your-domain'} else: raise return (settings.PAYPAL_CGI_URL + '?cmd=_grant-permission&request_token=%s' % r['token']) def get_permissions_token(request_token, verification_code): """ Send request for permissions token, after user has granted the requested permissions via the PayPal page we redirected them to. """ with statsd.timer('paypal.permissions.token'): r = _call(settings.PAYPAL_PERMISSIONS_URL + 'GetAccessToken', {'token': request_token, 'verifier': verification_code}) return r['token'] def _call(url, paypal_data, ip=None): request = urllib2.Request(url) if 'requestEnvelope.errorLanguage' not in paypal_data: paypal_data['requestEnvelope.errorLanguage'] = 'en_US' for key, value in [ ('security-userid', settings.PAYPAL_EMBEDDED_AUTH['USER']), ('security-password', settings.PAYPAL_EMBEDDED_AUTH['PASSWORD']), ('security-signature', settings.PAYPAL_EMBEDDED_AUTH['SIGNATURE']), ('application-id', settings.PAYPAL_APP_ID), ('request-data-format', 'NV'), ('response-data-format', 'NV')]: request.add_header('X-PAYPAL-%s' % key.upper(), value) if ip: request.add_header('X-PAYPAL-DEVICE-IPADDRESS', ip) opener = urllib2.build_opener() try: with socket_timeout(10): feeddata = opener.open(request, urlencode(paypal_data)).read() except Exception, error: paypal_log.error('HTTP Error: %s' % error) raise response = dict(urlparse.parse_qsl(feeddata)) if 'error(0).errorId' in response: error = errors.get(response['error(0).errorId'], PaypalError) paypal_log.error('Paypal Error: %s' % response['error(0).message']) raise error(response['error(0).message']) return response def check_paypal_id(name): """ Use the button API to check if name is a valid Paypal id. Returns bool(valid), str(msg). msg will be None if there wasn't an error. """ d = dict(version=settings.PAYPAL_API_VERSION, buttoncode='cleartext', buttontype='donate', method='BMCreateButton', l_buttonvar0='business=%s' % name) # TODO(andym): remove this once this is all live and settled down. if hasattr(settings, 'PAYPAL_CGI_AUTH'): d['user'] = settings.PAYPAL_CGI_AUTH['USER'] d['pwd'] = settings.PAYPAL_CGI_AUTH['PASSWORD'] d['signature'] = settings.PAYPAL_CGI_AUTH['SIGNATURE'] else: # In production if PAYPAL_CGI_AUTH doesn't get defined yet, # fall back to the existing values. d.update(dict(user=settings.PAYPAL_USER, pwd=settings.PAYPAL_PASSWORD, signature=settings.PAYPAL_SIGNATURE)) with socket_timeout(10): r = urllib.urlopen(settings.PAYPAL_API_URL, urlencode(d)) response = dict(urlparse.parse_qsl(r.read())) valid = response['ACK'] == 'Success' msg = None if valid else response['L_LONGMESSAGE0'] return valid, msg @contextlib.contextmanager def socket_timeout(timeout): """Context manager to temporarily set the default socket timeout.""" old = socket.getdefaulttimeout() try: yield finally: socket.setdefaulttimeout(old)