diff --git a/apps/addons/tests/test_views.py b/apps/addons/tests/test_views.py index daaf7121c2..c9879dd436 100644 --- a/apps/addons/tests/test_views.py +++ b/apps/addons/tests/test_views.py @@ -28,7 +28,7 @@ from addons import cron from addons.models import (Addon, AddonDependency, AddonUpsell, AddonUser, Charity, Category) from files.models import File -from market.models import AddonPremium +from market.models import AddonPremium, AddonPurchase from paypal.tests import other_error from stats.models import Contribution from translations.helpers import truncate @@ -216,7 +216,7 @@ class TestContributeEmbedded(amo.tests.TestCase): class TestPurchaseEmbedded(amo.tests.TestCase): - fixtures = ['base/apps', 'base/addon_592', 'prices'] + fixtures = ['base/apps', 'base/addon_592', 'base/users', 'prices'] def setUp(self): waffle.models.Switch.objects.create(name='marketplace', active=True) @@ -225,6 +225,7 @@ class TestPurchaseEmbedded(amo.tests.TestCase): status=amo.STATUS_PUBLIC) AddonPremium.objects.create(addon=self.addon, price_id=1) self.purchase_url = reverse('addons.purchase', args=[self.addon.slug]) + self.client.login(username='regular@mozilla.com', password='password') def test_premium_only(self): self.addon.update(premium_type=amo.ADDON_FREE) @@ -256,6 +257,70 @@ class TestPurchaseEmbedded(amo.tests.TestCase): res = self.client.get_ajax(self.purchase_url) assert json.loads(res.content)['error'].startswith('There was an') + @patch('paypal.get_paykey') + def test_paykey_contribution(self, get_paykey): + get_paykey.return_value = 'some-pay-key' + self.client.get_ajax(self.purchase_url) + cons = Contribution.objects.filter(type=amo.CONTRIB_PENDING) + eq_(cons.count(), 1) + eq_(cons[0].amount, Decimal('0.99')) + + def make_contribution(self): + return Contribution.objects.create(type=amo.CONTRIB_PENDING, + uuid='123', addon=self.addon, + paykey='1234') + + def get_url(self, status): + return reverse('addons.purchase.finished', + args=[self.addon.slug, status]) + + @patch('paypal.check_purchase') + def test_check_purchase(self, check_purchase): + check_purchase.return_value = 'COMPLETED' + self.make_contribution() + self.client.get_ajax('%s?uuid=%s' % (self.get_url('complete'), '123')) + cons = Contribution.objects.all() + eq_(cons.count(), 1) + eq_(cons[0].type, amo.CONTRIB_PURCHASE) + eq_(cons[0].uuid, None) + + @patch('paypal.check_purchase') + def test_check_addon_purchase(self, check_purchase): + check_purchase.return_value = 'COMPLETED' + self.make_contribution() + self.client.get_ajax('%s?uuid=%s' % (self.get_url('complete'), '123')) + eq_(AddonPurchase.objects.filter(addon=self.addon).count(), 1) + + @patch('paypal.check_purchase') + def test_check_cancel(self, check_purchase): + check_purchase.return_value = 'COMPLETED' + self.make_contribution() + self.client.get_ajax('%s?uuid=%s' % (self.get_url('cancel'), '123')) + eq_(Contribution.objects.filter(type=amo.CONTRIB_PURCHASE).count(), 0) + + @patch('paypal.check_purchase') + def test_check_wrong_uuid(self, check_purchase): + check_purchase.return_value = 'COMPLETED' + self.make_contribution() + self.assertRaises(Contribution.DoesNotExist, + self.client.get_ajax, + '%s?uuid=%s' % (self.get_url('complete'), 'foo')) + + @patch('paypal.check_purchase') + def test_check_pending(self, check_purchase): + check_purchase.return_value = 'PENDING' + self.make_contribution() + self.client.get_ajax('%s?uuid=%s' % (self.get_url('complete'), '123')) + eq_(Contribution.objects.filter(type=amo.CONTRIB_PURCHASE).count(), 0) + + @patch('paypal.check_purchase') + def test_check_pending_error(self, check_purchase): + check_purchase.side_effect = Exception('wtf') + self.make_contribution() + url = '%s?uuid=%s' % (self.get_url('complete'), '123') + res = self.client.get_ajax(url) + eq_(res.context['status'], 'ERROR') + class TestDeveloperPages(amo.tests.TestCase): fixtures = ['base/apps', 'base/addon_3615', 'base/addon_592', @@ -351,7 +416,6 @@ class TestDeveloperPages(amo.tests.TestCase): addon.save() url = reverse('addons.meet', args=['592']) r = self.client.get(url, follow=True) - doc = pq(r.content) eq_(pq(r.content)('#about-addon b').length, 2) diff --git a/apps/addons/urls.py b/apps/addons/urls.py index 952f74a93b..f0c7969314 100644 --- a/apps/addons/urls.py +++ b/apps/addons/urls.py @@ -31,6 +31,8 @@ detail_patterns = patterns('', name='addons.paypal'), url('^purchase/$', views.purchase, name='addons.purchase'), + url('^purchase/(?Pcancel|complete)$', views.purchase_complete, + name='addons.purchase.finished'), url('^about$', lambda r, addon_id: redirect('addons.installed', addon_id, permanent=True), diff --git a/apps/addons/views.py b/apps/addons/views.py index a58f764eec..28b63e1c27 100644 --- a/apps/addons/views.py +++ b/apps/addons/views.py @@ -33,6 +33,7 @@ from amo.urlresolvers import reverse from abuse.models import send_abuse_report from bandwagon.models import Collection, CollectionFeature, CollectionPromo from devhub.decorators import dev_required +from market.models import AddonPurchase import paypal from reviews.forms import ReviewForm from reviews.models import Review, GroupedRating @@ -465,27 +466,37 @@ def developers(request, addon, page): @login_required @addon_view def purchase(request, addon): - if not waffle.switch_is_active('marketplace'): - raise http.Http404 - - if (not addon.is_premium() or not addon.premium): + if not waffle.switch_is_active('marketplace') or not addon.is_premium(): raise http.Http404 + log.debug('Starting purchase of addon: %s by user: %s' + % (addon.pk, request.amo_user.pk)) amount = addon.premium.price.price + source = request.GET.get('source', '') uuid_ = hashlib.md5(str(uuid.uuid4())).hexdigest() + # l10n: {0} is the addon name contrib_for = _(u'Purchase of {0}').format(jinja2.escape(addon.name)) paykey, error = '', '' try: paykey = paypal.get_paykey(dict(uuid=uuid_, slug=addon.slug, - amount=amount, memo=contrib_for, - email=addon.paypal_id, - ip=request.META.get('REMOTE_ADDR'))) - except Exception: + amount=amount, memo=contrib_for, email=addon.paypal_id, + ip=request.META.get('REMOTE_ADDR'), + pattern='addons.purchase.finished')) + except: log.error('Error getting paykey, purchase of addon: %s' % addon.pk, exc_info=True) error = _('There was an error communicating with PayPal.') + if paykey: + contrib = Contribution(addon_id=addon.id, amount=amount, + source=source, source_locale=request.LANG, + uuid=str(uuid_), type=amo.CONTRIB_PENDING, + paykey=paykey) + contrib.save() + + log.debug('Got paykey for addon: %s by user: %s' + % (addon.pk, request.amo_user.pk)) url = '%s?paykey=%s' % (settings.PAYPAL_FLOW_URL, paykey) if request.GET.get('result_type') == 'json' or request.is_ajax(): return http.HttpResponse(json.dumps({'url': url, @@ -495,6 +506,44 @@ def purchase(request, addon): return http.HttpResponseRedirect(url) +# TODO(andym): again, remove this onece we figure out logged out flow. +@login_required +@addon_view +def purchase_complete(request, addon, status): + if not waffle.switch_is_active('marketplace') or not addon.is_premium(): + raise http.Http404 + + result = '' + if status == 'complete': + con = Contribution.objects.get(uuid=request.GET.get('uuid'), + type=amo.CONTRIB_PENDING) + log.debug('Check purchase paypal addon: %s, user: %s, paykey: %s' + % (addon.pk, request.amo_user.pk, con.paykey[:10])) + try: + result = paypal.check_purchase(con.paykey) + except: + log.error('Check purchase paypal addon: %s, user: %s, paykey: %s' + % (addon.pk, request.amo_user.pk, con.paykey[:10]), + exc_info=True) + result = 'ERROR' + + log.debug('Paypal returned: %s for paykey: %s' + % (result, con.paykey[:10])) + if result == 'COMPLETED': + # Sadly we are changing things on a GET. + # Create an addon purchase. + AddonPurchase.objects.create(addon=addon, user=request.amo_user) + # Markup the contribution suitably + con.type = amo.CONTRIB_PURCHASE + con.uuid = None + con.save() + + response = jingo.render(request, 'addons/paypal_result.html', + {'addon': addon, 'status': result}) + response['x-frame-options'] = 'allow' + return response + + @addon_view def contribute(request, addon): contrib_type = request.GET.get('type', 'suggested') @@ -514,15 +563,16 @@ def contribute(request, addon): name, paypal_id = addon.charity.name, addon.charity.paypal else: name, paypal_id = addon.name, addon.paypal_id + # l10n: {0} is the addon name contrib_for = _(u'Contribution for {0}').format(jinja2.escape(name)) paykey, error = '', '' try: paykey = paypal.get_paykey(dict(uuid=contribution_uuid, - slug=addon.slug, amount=amount, - email=paypal_id, memo=contrib_for, - ip=request.META.get('REMOTE_ADDR'))) - except Exception: + slug=addon.slug, amount=amount, email=paypal_id, + memo=contrib_for, ip=request.META.get('REMOTE_ADDR'), + pattern='addons.paypal')) + except: log.error('Error getting paykey, contribution for addon: %s' % addon.pk, exc_info=True) error = _('There was an error communicating with PayPal.') diff --git a/apps/constants/base.py b/apps/constants/base.py index 39c17811fe..400594b813 100644 --- a/apps/constants/base.py +++ b/apps/constants/base.py @@ -281,6 +281,9 @@ CONTRIB_VOLUNTARY = 0 CONTRIB_PURCHASE = 1 CONTRIB_REFUND = 2 CONTRIB_CHARGEBACK = 3 +# We've started a transaction and we need to wait to see what +# paypal will return. +CONTRIB_PENDING = 4 CONTRIB_OTHER = 99 CONTRIB_TYPES = { diff --git a/apps/paypal/__init__.py b/apps/paypal/__init__.py index 45cd01e7c6..ad30533cd7 100644 --- a/apps/paypal/__init__.py +++ b/apps/paypal/__init__.py @@ -29,15 +29,15 @@ paypal_log = commonware.log.getLogger('z.paypal') def get_paykey(data): """ Gets a paykey from Paypal. Need to pass in the following in data: - slug: addon, will form urls for where user goes back to (required) + 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 """ - complete = reverse('addons.paypal', args=[data['slug'], 'complete']) - cancel = reverse('addons.paypal', args=[data['slug'], 'cancel']) + complete = reverse(data['pattern'], args=[data['slug'], 'complete']) + cancel = reverse(data['pattern'], args=[data['slug'], 'cancel']) uuid_qs = urllib.urlencode({'uuid': data['uuid']}) paypal_data = { @@ -59,14 +59,30 @@ def get_paykey(data): with statsd.timer('paypal.paykey.retrieval'): try: - response = _call(settings.PAYPAL_PAY_URL, paypal_data, + response = _call(settings.PAYPAL_PAY_URL + 'Pay', paypal_data, ip=data['ip']) except AuthError, error: - paypal_log.error('Authentication error: %s' % error) - raise + 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 check_refund_permission(token): """ Asks PayPal whether the PayPal ID for this account has granted diff --git a/apps/paypal/tests.py b/apps/paypal/tests.py index ef2aca1d73..bf948b1f81 100644 --- a/apps/paypal/tests.py +++ b/apps/paypal/tests.py @@ -1,8 +1,6 @@ from cStringIO import StringIO from django.conf import settings -from amo.urlresolvers import reverse -from amo.helpers import absolutify import mock from nose.tools import eq_ @@ -26,14 +24,17 @@ auth_error = ('error(0).errorId=520003' other_error = ('error(0).errorId=520001' '&error(0).message=Foo') +good_check_purchase = ('status=CREATED') # There is more, but I trimmed it. -class TestPayPal(amo.tests.TestCase): + +class TestPayKey(amo.tests.TestCase): def setUp(self): self.data = {'slug': 'xx', 'amount': 10, 'email': 'someone@somewhere.com', 'uuid': time.time(), - 'ip': '127.0.0.1'} + 'ip': '127.0.0.1', + 'pattern': 'addons.purchase.finished'} @mock.patch('urllib2.OpenerDirector.open') def test_auth_fails(self, opener): @@ -54,6 +55,24 @@ class TestPayPal(amo.tests.TestCase): # Remove _ and run if you'd like to try unmocked. return paypal.get_paykey(self.data) + def _test_check_purchase_no_mock(self): + # Remove _ and run if you'd like to try this unmocked. + key = paypal.get_paykey(self.data) + eq_(paypal.check_purchase(key), 'CREATED') + + +class TestPurchase(amo.tests.TestCase): + + @mock.patch('urllib2.OpenerDirector.open') + def test_check_purchase(self, opener): + opener.return_value = StringIO(good_check_purchase) + eq_(paypal.check_purchase('some-paykey'), 'CREATED') + + @mock.patch('urllib2.OpenerDirector.open') + def test_check_purchase_fails(self, opener): + opener.return_value = StringIO(other_error) + eq_(paypal.check_purchase('some-paykey'), False) + @mock.patch('paypal.urllib.urlopen') def test_check_paypal_id(urlopen_mock): diff --git a/settings.py b/settings.py index 7145bc3b69..de8d33aae5 100644 --- a/settings.py +++ b/settings.py @@ -814,7 +814,7 @@ PAYPAL_BN = '' PAYPAL_CGI_URL = 'https://www.paypal.com/cgi-bin/webscr' PAYPAL_CGI_AUTH = {'USER': '', 'PASSWORD': '', 'SIGNATURE': ''} -PAYPAL_PAY_URL = 'https://svcs.paypal.com/AdaptivePayments/Pay' +PAYPAL_PAY_URL = 'https://svcs.paypal.com/AdaptivePayments/' PAYPAL_FLOW_URL = 'https://paypal.com/webapps/adaptivepayment/flow/pay' PAYPAL_PERMISSIONS_URL = 'https://svcs.paypal.com/Permissions/' PAYPAL_JS_URL = 'https://www.paypalobjects.com/js/external/dg.js' @@ -1093,7 +1093,7 @@ ARECIBO_SERVER_URL = "" # Make AMO group posts and wait for 60 seconds when we get lots of errors. ARECIBO_SETTINGS = { 'GROUP_POSTS': True, - 'GROUP_WAIT': 60 + 'GROUP_WAIT': 60, } # A whitelist of domains that the authentication script will redirect to upon