Receipt page now also handles Cybersource payment failures.
XCOM-398
This commit is contained in:
@@ -4,10 +4,11 @@ import json
|
||||
from uuid import uuid4
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from ddt import ddt, data
|
||||
import ddt
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
import mock
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@@ -35,7 +36,7 @@ class UserMixin(object):
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
@ddt
|
||||
@ddt.ddt
|
||||
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY)
|
||||
class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
@@ -102,7 +103,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
|
||||
self.client.logout()
|
||||
self.assertEqual(403, self._post_to_view().status_code)
|
||||
|
||||
@data('delete', 'get', 'put')
|
||||
@ddt.data('delete', 'get', 'put')
|
||||
def test_post_required(self, method):
|
||||
"""
|
||||
Verify that the view only responds to POST operations.
|
||||
@@ -157,7 +158,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
|
||||
else:
|
||||
self.assertResponsePaymentData(response)
|
||||
|
||||
@data(True, False)
|
||||
@ddt.data(True, False)
|
||||
def test_course_with_honor_seat_sku(self, user_is_active):
|
||||
"""
|
||||
If the course has a SKU, the view should get authorization from the E-Commerce API before enrolling
|
||||
@@ -172,7 +173,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, UserMixin, ModuleStoreTestCase)
|
||||
with mock_create_basket(response=return_value):
|
||||
self._test_successful_ecommerce_api_call()
|
||||
|
||||
@data(True, False)
|
||||
@ddt.data(True, False)
|
||||
def test_course_with_paid_seat_sku(self, user_is_active):
|
||||
"""
|
||||
If the course has a SKU, the view should return data that the client
|
||||
@@ -341,11 +342,63 @@ class BasketOrderViewTests(UserMixin, TestCase):
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class ReceiptViewTests(TestCase):
|
||||
@ddt.ddt
|
||||
class ReceiptViewTests(UserMixin, TestCase):
|
||||
""" Tests for the receipt view. """
|
||||
|
||||
def test_login_required(self):
|
||||
""" The view should redirect to the login page if the user is not logged in. """
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse('commerce:checkout_receipt'))
|
||||
response = self.client.post(reverse('commerce:checkout_receipt'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def post_to_receipt_page(self, post_data):
|
||||
""" DRY helper """
|
||||
response = self.client.post(reverse('commerce:checkout_receipt'), params={'basket_id': 1}, data=post_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
@ddt.data('decision', 'reason_code', 'signed_field_names', None)
|
||||
def test_is_cybersource(self, post_key):
|
||||
"""
|
||||
Ensure the view uses three specific POST keys to detect a request initiated by Cybersource.
|
||||
"""
|
||||
self._login()
|
||||
post_data = {'decision': 'REJECT', 'reason_code': '200', 'signed_field_names': 'dummy'}
|
||||
if post_key is not None:
|
||||
# a key will be missing; we will not expect the receipt page to handle a cybersource decision
|
||||
del post_data[post_key]
|
||||
expected_pattern = r"<title>(\s+)Receipt"
|
||||
else:
|
||||
expected_pattern = r"<title>(\s+)Payment Failed"
|
||||
response = self.post_to_receipt_page(post_data)
|
||||
self.assertRegexpMatches(response.content, expected_pattern)
|
||||
|
||||
@ddt.data('ACCEPT', 'REJECT', 'ERROR')
|
||||
def test_cybersource_decision(self, decision):
|
||||
"""
|
||||
Ensure the view renders a page appropriately depending on the Cybersource decision.
|
||||
"""
|
||||
self._login()
|
||||
post_data = {'decision': decision, 'reason_code': '200', 'signed_field_names': 'dummy'}
|
||||
expected_pattern = r"<title>(\s+)Receipt" if decision == 'ACCEPT' else r"<title>(\s+)Payment Failed"
|
||||
response = self.post_to_receipt_page(post_data)
|
||||
self.assertRegexpMatches(response.content, expected_pattern)
|
||||
|
||||
@ddt.data(True, False)
|
||||
@mock.patch('commerce.views.is_user_payment_error')
|
||||
def test_cybersource_message(self, is_user_message_expected, mock_is_user_payment_error):
|
||||
"""
|
||||
Ensure that the page displays the right message for the reason_code (it
|
||||
may be a user error message or a system error message).
|
||||
"""
|
||||
mock_is_user_payment_error.return_value = is_user_message_expected
|
||||
self._login()
|
||||
response = self.post_to_receipt_page({'decision': 'REJECT', 'reason_code': '99', 'signed_field_names': 'dummy'})
|
||||
self.assertTrue(mock_is_user_payment_error.called)
|
||||
self.assertTrue(mock_is_user_payment_error.call_args[0][0], '99')
|
||||
|
||||
user_message = "There was a problem with this transaction"
|
||||
system_message = "A system error occurred while processing your payment"
|
||||
self.assertRegexpMatches(response.content, user_message if is_user_message_expected else system_message)
|
||||
self.assertNotRegexpMatches(response.content, user_message if not is_user_message_expected else system_message)
|
||||
|
||||
@@ -3,7 +3,6 @@ import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from ecommerce_api_client import exceptions
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -26,6 +25,8 @@ from student.models import CourseEnrollment
|
||||
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from util.json_request import JsonResponse
|
||||
from verify_student.models import SoftwareSecurePhotoVerification
|
||||
from shoppingcart.processors.CyberSource2 import is_user_payment_error
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -137,7 +138,6 @@ class BasketsView(APIView):
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@cache_page(1800)
|
||||
def checkout_cancel(_request):
|
||||
""" Checkout/payment cancellation view. """
|
||||
context = {'payment_support_email': microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)}
|
||||
@@ -148,9 +148,46 @@ def checkout_cancel(_request):
|
||||
@login_required
|
||||
def checkout_receipt(request):
|
||||
""" Receipt view. """
|
||||
|
||||
page_title = _('Receipt')
|
||||
is_payment_complete = True
|
||||
payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
|
||||
payment_support_link = '<a href=\"mailto:{email}\">{email}</a>'.format(email=payment_support_email)
|
||||
|
||||
is_cybersource = all(k in request.POST for k in ('signed_field_names', 'decision', 'reason_code'))
|
||||
if is_cybersource and request.POST['decision'] != 'ACCEPT':
|
||||
# Cybersource may redirect users to this view if it couldn't recover
|
||||
# from an error while capturing payment info.
|
||||
is_payment_complete = False
|
||||
page_title = _('Payment Failed')
|
||||
reason_code = request.POST['reason_code']
|
||||
# if the problem was with the info submitted by the user, we present more detailed messages.
|
||||
if is_user_payment_error(reason_code):
|
||||
error_summary = _("There was a problem with this transaction. You have not been charged.")
|
||||
error_text = _(
|
||||
"Make sure your information is correct, or try again with a different card or another form of payment."
|
||||
)
|
||||
else:
|
||||
error_summary = _("A system error occurred while processing your payment. You have not been charged.")
|
||||
error_text = _("Please wait a few minutes and then try again.")
|
||||
for_help_text = _("For help, contact {payment_support_link}.").format(payment_support_link=payment_support_link)
|
||||
else:
|
||||
# if anything goes wrong rendering the receipt, it indicates a problem fetching order data.
|
||||
error_summary = _("An error occurred while creating your receipt.")
|
||||
error_text = None # nothing particularly helpful to say if this happens.
|
||||
for_help_text = _(
|
||||
"If your course does not appear on your dashboard, contact {payment_support_link}."
|
||||
).format(payment_support_link=payment_support_link)
|
||||
|
||||
context = {
|
||||
'page_title': page_title,
|
||||
'is_payment_complete': is_payment_complete,
|
||||
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
|
||||
'verified': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists()
|
||||
'verified': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists(),
|
||||
'error_summary': error_summary,
|
||||
'error_text': error_text,
|
||||
'for_help_text': for_help_text,
|
||||
'payment_support_email': payment_support_email,
|
||||
}
|
||||
return render_to_response('commerce/checkout_receipt.html', context)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from collections import OrderedDict, defaultdict
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from hashlib import sha256
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext as _, ugettext_noop
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from shoppingcart.models import Order
|
||||
from shoppingcart.processors.exceptions import *
|
||||
@@ -41,6 +41,10 @@ from microsite_configuration import microsite
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Translators: this text appears when an unfamiliar error code occurs during payment,
|
||||
# for which we don't know a user-friendly message to display in advance.
|
||||
DEFAULT_REASON = ugettext_noop("UNKNOWN REASON")
|
||||
|
||||
|
||||
def process_postpay_callback(params):
|
||||
"""
|
||||
@@ -582,18 +586,29 @@ CARDTYPE_MAP.update(
|
||||
}
|
||||
)
|
||||
|
||||
REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON")
|
||||
|
||||
# Note: these messages come directly from official Cybersource documentation at:
|
||||
# http://apps.cybersource.com/library/documentation/dev_guides/CC_Svcs_SO_API/html/wwhelp/wwhimpl/js/html/wwhelp.htm#href=reason_codes.html
|
||||
REASONCODE_MAP = defaultdict(lambda: DEFAULT_REASON)
|
||||
REASONCODE_MAP.update(
|
||||
{
|
||||
'100': _('Successful transaction.'),
|
||||
'101': _('The request is missing one or more required fields.'),
|
||||
'102': _('One or more fields in the request contains invalid data.'),
|
||||
'104': dedent(_(
|
||||
"""
|
||||
The access_key and transaction_uuid fields for this authorization request matches the access_key and
|
||||
transaction_uuid of another authorization request that you sent in the last 15 minutes.
|
||||
Possible fix: retry the payment after 15 minutes.
|
||||
The merchant reference code for this authorization request matches the merchant reference code of another
|
||||
authorization request that you sent within the past 15 minutes.
|
||||
Possible action: Resend the request with a unique merchant reference code.
|
||||
""")),
|
||||
'110': _('Only a partial amount was approved.'),
|
||||
'150': _('General system failure.'),
|
||||
'151': dedent(_(
|
||||
"""
|
||||
The request was received but there was a server timeout. This error does not include timeouts between the
|
||||
client and the server.
|
||||
""")),
|
||||
'152': _('The request was received, but a service did not finish running in time.'),
|
||||
'200': dedent(_(
|
||||
"""
|
||||
The authorization request was approved by the issuing bank but declined by CyberSource
|
||||
@@ -603,63 +618,101 @@ REASONCODE_MAP.update(
|
||||
"""
|
||||
The issuing bank has questions about the request. You do not receive an
|
||||
authorization code programmatically, but you might receive one verbally by calling the processor.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'202': dedent(_(
|
||||
"""
|
||||
Expired card. You might also receive this if the expiration date you
|
||||
provided does not match the date the issuing bank has on file.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'203': dedent(_(
|
||||
"""
|
||||
General decline of the card. No other information provided by the issuing bank.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'),
|
||||
'204': _('Insufficient funds in the account. Possible action: retry with another form of payment.'),
|
||||
# 205 was Stolen or lost card. Might as well not show this message to the person using such a card.
|
||||
'205': _('Stolen or lost card'),
|
||||
'207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'),
|
||||
'205': _('Stolen or lost card.'),
|
||||
'207': _('Issuing bank unavailable. Possible action: retry again after a few minutes.'),
|
||||
'208': dedent(_(
|
||||
"""
|
||||
Inactive card or card not authorized for card-not-present transactions.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'),
|
||||
'211': _('Invalid card verification number (CVN). Possible fix: retry with another form of payment'),
|
||||
'209': _('CVN did not match.'),
|
||||
'210': _('The card has reached the credit limit. Possible action: retry with another form of payment.'),
|
||||
'211': _('Invalid card verification number (CVN). Possible action: retry with another form of payment.'),
|
||||
# 221 was The customer matched an entry on the processor's negative file.
|
||||
# Might as well not show this message to the person using such a card.
|
||||
'221': _('The customer matched an entry on the processors negative file.'),
|
||||
'222': _('Account frozen. Possible fix: retry with another form of payment'),
|
||||
'222': _('Account frozen. Possible action: retry with another form of payment.'),
|
||||
'230': dedent(_(
|
||||
"""
|
||||
The authorization request was approved by the issuing bank but declined by
|
||||
CyberSource because it did not pass the CVN check.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'231': _('Invalid account number. Possible fix: retry with another form of payment'),
|
||||
'231': _('Invalid account number. Possible action: retry with another form of payment.'),
|
||||
'232': dedent(_(
|
||||
"""
|
||||
The card type is not accepted by the payment processor.
|
||||
Possible fix: retry with another form of payment
|
||||
Possible action: retry with another form of payment.
|
||||
""")),
|
||||
'233': _('General decline by the processor. Possible fix: retry with another form of payment'),
|
||||
'233': _('General decline by the processor. Possible action: retry with another form of payment.'),
|
||||
'234': _(
|
||||
"There is a problem with the information in your CyberSource account. Please let us know at {0}"
|
||||
).format(settings.PAYMENT_SUPPORT_EMAIL),
|
||||
'236': _('Processor Failure. Possible fix: retry the payment'),
|
||||
'235': _('The requested capture amount exceeds the originally authorized amount.'),
|
||||
'236': _('Processor Failure. Possible action: retry the payment'),
|
||||
'237': _('The authorization has already been reversed.'),
|
||||
'238': _('The authorization has already been captured.'),
|
||||
'239': _('The requested transaction amount must match the previous transaction amount.'),
|
||||
'240': dedent(_(
|
||||
"""
|
||||
The card type sent is invalid or does not correlate with the credit card number.
|
||||
Possible fix: retry with the same card or another form of payment
|
||||
Possible action: retry with the same card or another form of payment.
|
||||
""")),
|
||||
'241': _('The request ID is invalid.'),
|
||||
'242': dedent(_(
|
||||
"""
|
||||
You requested a capture, but there is no corresponding, unused authorization record. Occurs if there was
|
||||
not a previously successful authorization request or if the previously successful authorization has already
|
||||
been used by another capture request.
|
||||
""")),
|
||||
'243': _('The transaction has already been settled or reversed.'),
|
||||
'246': dedent(_(
|
||||
"""
|
||||
Either the capture or credit is not voidable because the capture or credit information has already been
|
||||
submitted to your processor, or you requested a void for a type of transaction that cannot be voided.
|
||||
""")),
|
||||
'247': _('You requested a credit for a capture that was previously voided.'),
|
||||
'250': _('The request was received, but there was a timeout at the payment processor.'),
|
||||
'254': _('Stand-alone credits are not allowed.'),
|
||||
'475': _('The cardholder is enrolled for payer authentication'),
|
||||
'476': _('Payer authentication could not be authenticated'),
|
||||
'520': dedent(_(
|
||||
"""
|
||||
The authorization request was approved by the issuing bank but declined by CyberSource based
|
||||
on your legacy Smart Authorization settings.
|
||||
Possible fix: retry with a different form of payment.
|
||||
Possible action: retry with a different form of payment.
|
||||
""")),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_user_payment_error(reason_code):
|
||||
"""
|
||||
Decide, based on the reason_code, whether or not it signifies a problem
|
||||
with something the user did (rather than a system error beyond the user's
|
||||
control).
|
||||
|
||||
This function is used to determine whether we can/should show the user a
|
||||
message with suggested actions to fix the problem, or simply apologize and
|
||||
ask her to try again later.
|
||||
"""
|
||||
reason_code = str(reason_code)
|
||||
if reason_code not in REASONCODE_MAP or REASONCODE_MAP[reason_code] == DEFAULT_REASON:
|
||||
return False
|
||||
|
||||
return (200 <= int(reason_code) <= 233) or int(reason_code) in (101, 102, 240)
|
||||
|
||||
@@ -50,8 +50,9 @@ var edx = edx || {};
|
||||
var self = this,
|
||||
orderId = $.url('?basket_id') || $.url('?payment-order-num');
|
||||
|
||||
if (orderId) {
|
||||
if (orderId && this.$el.data('is-payment-complete')==='True') {
|
||||
// Get the order details
|
||||
self.$el.removeClass('hidden');
|
||||
self.getReceiptData(orderId).then(self.renderReceipt, self.renderError);
|
||||
} else {
|
||||
self.renderError();
|
||||
|
||||
@@ -6,9 +6,7 @@ from django.utils.translation import ugettext as _
|
||||
<%inherit file="../main.html" />
|
||||
<%block name="bodyclass">register verification-process step-requirements</%block>
|
||||
|
||||
<%block name="pagetitle">
|
||||
${_("Receipt")}
|
||||
</%block>
|
||||
<%block name="pagetitle">${page_title}</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<script type="text/template" id="receipt-tpl">
|
||||
@@ -33,11 +31,17 @@ ${_("Receipt")}
|
||||
<i class="msg-icon icon fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
<div class="msg-content">
|
||||
<h3 class="title">
|
||||
<span class="sr">${ _("Error:") }</span>
|
||||
${ _("Error") }
|
||||
<span class="sr">${error_summary}</span>
|
||||
${error_summary}
|
||||
</h3>
|
||||
%if error_text:
|
||||
<div class="copy">
|
||||
<p>${ _("Could not retrieve payment information") }</p>
|
||||
<p>${error_text}</p>
|
||||
<br/>
|
||||
</div>
|
||||
%endif
|
||||
<div class="msg">
|
||||
<p>${for_help_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +50,7 @@ ${_("Receipt")}
|
||||
|
||||
<div class="container">
|
||||
<section class="wrapper carousel">
|
||||
<div id="receipt-container" class="pay-and-verify" data-platform-name='${platform_name}' data-verified='${verified}'>
|
||||
<div id="receipt-container" class="pay-and-verify hidden" data-is-payment-complete='${is_payment_complete}' data-platform-name='${platform_name}' data-verified='${verified}'>
|
||||
<h1>${_("Loading Order Data...")}</h1>
|
||||
<span>${ _("Please wait while we retrieve your order details.") }</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user