diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js index 6ceda7efcc..e9b97696e4 100644 --- a/common/static/js/spec_helpers/ajax_helpers.js +++ b/common/static/js/spec_helpers/ajax_helpers.js @@ -1,6 +1,6 @@ define(['sinon', 'underscore'], function(sinon, _) { - var fakeServer, fakeRequests, expectRequest, expectJsonRequest, - respondWithJson, respondWithError, respondWithTextError, responseWithNoContent; + var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest, + respondWithJson, respondWithError, respondWithTextError, respondWithNoContent; /* These utility methods are used by Jasmine tests to create a mock server or * get reference to mock requests. In either case, the cleanup (restore) is done with @@ -68,6 +68,20 @@ define(['sinon', 'underscore'], function(sinon, _) { expect(JSON.parse(request.requestBody)).toEqual(jsonRequest); }; + /** + * Intended for use with POST requests using application/x-www-form-urlencoded. + */ + expectPostRequest = function(requests, url, body, requestIndex) { + var request; + if (_.isUndefined(requestIndex)) { + requestIndex = requests.length - 1; + } + request = requests[requestIndex]; + expect(request.url).toEqual(url); + expect(request.method).toEqual("POST"); + expect(_.difference(request.requestBody.split('&'), body.split('&'))).toEqual([]); + }; + respondWithJson = function(requests, jsonResponse, requestIndex) { if (_.isUndefined(requestIndex)) { requestIndex = requests.length - 1; @@ -122,6 +136,7 @@ define(['sinon', 'underscore'], function(sinon, _) { 'requests': fakeRequests, 'expectRequest': expectRequest, 'expectJsonRequest': expectJsonRequest, + 'expectPostRequest': expectPostRequest, 'respondWithJson': respondWithJson, 'respondWithError': respondWithError, 'respondWithTextError': respondWithTextError, diff --git a/common/test/acceptance/pages/lms/pay_and_verify.py b/common/test/acceptance/pages/lms/pay_and_verify.py index 319298ea64..0b27a0d69d 100644 --- a/common/test/acceptance/pages/lms/pay_and_verify.py +++ b/common/test/acceptance/pages/lms/pay_and_verify.py @@ -76,7 +76,7 @@ class PaymentAndVerificationFlow(PageObject): def proceed_to_payment(self): """Interact with the payment button.""" - self.q(css="#pay_button").click() + self.q(css=".payment-button").click() FakePaymentPage(self.browser, self._course_id).wait_for_page() diff --git a/lms/djangoapps/commerce/api.py b/lms/djangoapps/commerce/api.py index 17ac85b2ee..acb19ffaf6 100644 --- a/lms/djangoapps/commerce/api.py +++ b/lms/djangoapps/commerce/api.py @@ -60,26 +60,57 @@ class EcommerceAPI(object): } url = '{base_url}/orders/{order_number}/'.format(base_url=self.url, order_number=order_number) return requests.get(url, headers=headers, timeout=self.timeout) - return self._call_ecommerce_service(get) - def create_order(self, user, sku): + data = self._call_ecommerce_service(get) + return data['number'], data['status'], data + + def get_processors(self, user): """ - Create a new order. + Retrieve the list of available payment processors. - Arguments - user -- User for which the order should be created. - sku -- SKU of the course seat being ordered. - - Returns a tuple with the order number, order status, API response data. + Returns a list of strings. """ - def create(): - """Internal service call to create an order. """ + def get(): + """Internal service call to retrieve the processor list. """ headers = { 'Content-Type': 'application/json', 'Authorization': 'JWT {}'.format(self._get_jwt(user)) } - url = '{}/orders/'.format(self.url) - return requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout) + url = '{base_url}/payment/processors/'.format(base_url=self.url) + return requests.get(url, headers=headers, timeout=self.timeout) + + return self._call_ecommerce_service(get) + + def create_basket(self, user, sku, payment_processor=None): + """Create a new basket and immediately trigger checkout. + + Note that while the API supports deferring checkout to a separate step, + as well as adding multiple products to the basket, this client does not + currently need that capability, so that case is not supported. + + Args: + user: the django.auth.User for which the basket should be created. + sku: a string containing the SKU of the course seat being ordered. + payment_processor: (optional) the name of the payment processor to + use for checkout. + + Returns: + A dictionary containing {id, order, payment_data}. + + Raises: + TimeoutError: the request to the API server timed out. + InvalidResponseError: the API server response was not understood. + """ + def create(): + """Internal service call to create a basket. """ + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'JWT {}'.format(self._get_jwt(user)) + } + url = '{}/baskets/'.format(self.url) + data = {'products': [{'sku': sku}], 'checkout': True, 'payment_processor_name': payment_processor} + return requests.post(url, data=json.dumps(data), headers=headers, timeout=self.timeout) + return self._call_ecommerce_service(create) @staticmethod @@ -92,7 +123,7 @@ class EcommerceAPI(object): Arguments call -- A callable function that makes a request to the E-Commerce Service. - Returns a tuple with the order number, order status, API response data. + Returns a dict of JSON-decoded API response data. """ try: response = call() @@ -109,7 +140,7 @@ class EcommerceAPI(object): status_code = response.status_code if status_code == HTTP_200_OK: - return data['number'], data['status'], data + return data else: msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s' msg_kwargs = { diff --git a/lms/djangoapps/commerce/constants.py b/lms/djangoapps/commerce/constants.py index b8266d8d47..729830a2e8 100644 --- a/lms/djangoapps/commerce/constants.py +++ b/lms/djangoapps/commerce/constants.py @@ -4,13 +4,8 @@ class OrderStatus(object): """Constants representing all known order statuses. """ OPEN = 'Open' - ORDER_CANCELLED = 'Order Cancelled' - BEING_PROCESSED = 'Being Processed' - PAYMENT_CANCELLED = 'Payment Cancelled' - PAID = 'Paid' FULFILLMENT_ERROR = 'Fulfillment Error' COMPLETE = 'Complete' - REFUNDED = 'Refunded' class Messages(object): diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index 75eede3957..580b0f5170 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -6,7 +6,6 @@ import jwt import mock from commerce.api import EcommerceAPI -from commerce.constants import OrderStatus class EcommerceApiTestMixin(object): @@ -14,12 +13,19 @@ class EcommerceApiTestMixin(object): ECOMMERCE_API_URL = 'http://example.com/api' ECOMMERCE_API_SIGNING_KEY = 'edx' + BASKET_ID = 7 ORDER_NUMBER = '100004' + PROCESSOR = 'test-processor' + PAYMENT_DATA = { + 'payment_processor_name': PROCESSOR, + 'payment_form_data': {}, + 'payment_page_url': 'http://example.com/pay', + } + ORDER_DATA = {'number': ORDER_NUMBER} ECOMMERCE_API_SUCCESSFUL_BODY = { - 'status': OrderStatus.COMPLETE, - 'number': ORDER_NUMBER, - 'payment_processor': 'cybersource', - 'payment_parameters': {'orderNumber': ORDER_NUMBER} + 'id': BASKET_ID, + 'order': {'number': ORDER_NUMBER}, # never both None. + 'payment_data': PAYMENT_DATA, } ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name @@ -28,14 +34,18 @@ class EcommerceApiTestMixin(object): expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key) self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt)) - def assertValidOrderRequest(self, request, user, jwt_signing_key, sku): + def assertValidBasketRequest(self, request, user, jwt_signing_key, sku, processor): """ Verifies that an order request to the E-Commerce Service is valid. """ self.assertValidJWTAuthHeader(request, user, jwt_signing_key) - - self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku)) + expected_body_data = { + 'products': [{'sku': sku}], + 'checkout': True, + 'payment_processor_name': processor + } + self.assertEqual(json.loads(request.body), expected_body_data) self.assertEqual(request.headers['Content-Type'], 'application/json') - def _mock_ecommerce_api(self, status=200, body=None): + def _mock_ecommerce_api(self, status=200, body=None, is_payment_required=False): """ Mock calls to the E-Commerce API. @@ -43,27 +53,25 @@ class EcommerceApiTestMixin(object): """ self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.') - url = self.ECOMMERCE_API_URL + '/orders/' - body = body or self.ECOMMERCE_API_SUCCESSFUL_BODY_JSON + url = self.ECOMMERCE_API_URL + '/baskets/' + if body is None: + response_data = {'id': self.BASKET_ID, 'payment_data': None, 'order': None} + if is_payment_required: + response_data['payment_data'] = self.PAYMENT_DATA + else: + response_data['order'] = {'number': self.ORDER_NUMBER} + body = json.dumps(response_data) httpretty.register_uri(httpretty.POST, url, status=status, body=body) - class mock_create_order(object): # pylint: disable=invalid-name - """ Mocks calls to EcommerceAPI.create_order. """ + class mock_create_basket(object): # pylint: disable=invalid-name + """ Mocks calls to EcommerceAPI.create_basket. """ patch = None def __init__(self, **kwargs): - default_kwargs = { - 'return_value': ( - EcommerceApiTestMixin.ORDER_NUMBER, - OrderStatus.COMPLETE, - EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY - ) - } - + default_kwargs = {'return_value': EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY} default_kwargs.update(kwargs) - - self.patch = mock.patch.object(EcommerceAPI, 'create_order', mock.Mock(**default_kwargs)) + self.patch = mock.patch.object(EcommerceAPI, 'create_basket', mock.Mock(**default_kwargs)) def __enter__(self): self.patch.start() diff --git a/lms/djangoapps/commerce/tests/test_api.py b/lms/djangoapps/commerce/tests/test_api.py index 43a01a0746..5caf643cf4 100644 --- a/lms/djangoapps/commerce/tests/test_api.py +++ b/lms/djangoapps/commerce/tests/test_api.py @@ -10,7 +10,6 @@ import httpretty from requests import Timeout from commerce.api import EcommerceAPI -from commerce.constants import OrderStatus from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError from commerce.tests import EcommerceApiTestMixin from student.tests.factories import UserFactory @@ -26,7 +25,7 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): def setUp(self): super(EcommerceAPITests, self).setUp() - self.url = reverse('commerce:orders') + self.url = reverse('commerce:baskets') self.user = UserFactory() self.api = EcommerceAPI() @@ -48,35 +47,40 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): self.assertRaises(InvalidConfigurationError, EcommerceAPI) @httpretty.activate - def test_create_order(self): + @data(True, False) + def test_create_basket(self, is_payment_required): """ Verify the method makes a call to the E-Commerce API with the correct headers and data. """ - self._mock_ecommerce_api() - number, status, body = self.api.create_order(self.user, self.SKU) + self._mock_ecommerce_api(is_payment_required=is_payment_required) + response_data = self.api.create_basket(self.user, self.SKU, self.PROCESSOR) # Validate the request sent to the E-Commerce API endpoint. request = httpretty.last_request() - self.assertValidOrderRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU) + self.assertValidBasketRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU, self.PROCESSOR) # Validate the data returned by the method - self.assertEqual(number, self.ORDER_NUMBER) - self.assertEqual(status, OrderStatus.COMPLETE) - self.assertEqual(body, self.ECOMMERCE_API_SUCCESSFUL_BODY) + self.assertEqual(response_data['id'], self.BASKET_ID) + if is_payment_required: + self.assertEqual(response_data['order'], None) + self.assertEqual(response_data['payment_data'], self.PAYMENT_DATA) + else: + self.assertEqual(response_data['order'], {"number": self.ORDER_NUMBER}) + self.assertEqual(response_data['payment_data'], None) @httpretty.activate @data(400, 401, 405, 406, 429, 500, 503) - def test_create_order_with_invalid_http_status(self, status): + def test_create_basket_with_invalid_http_status(self, status): """ If the E-Commerce API returns a non-200 status, the method should raise an InvalidResponseError. """ self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'})) - self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU) + self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR) @httpretty.activate - def test_create_order_with_invalid_json(self): + def test_create_basket_with_invalid_json(self): """ If the E-Commerce API returns un-parseable data, the method should raise an InvalidResponseError. """ self._mock_ecommerce_api(body='TOTALLY NOT JSON!') - self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU) + self.assertRaises(InvalidResponseError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR) @httpretty.activate - def test_create_order_with_timeout(self): + def test_create_basket_with_timeout(self): """ If the call to the E-Commerce API times out, the method should raise a TimeoutError. """ def request_callback(_request, _uri, _headers): @@ -85,4 +89,4 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): self._mock_ecommerce_api(body=request_callback) - self.assertRaises(TimeoutError, self.api.create_order, self.user, self.SKU) + self.assertRaises(TimeoutError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR) diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index f73110174b..3e637fe30e 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -9,7 +9,7 @@ from django.test.utils import override_settings from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from commerce.constants import OrderStatus, Messages +from commerce.constants import Messages from commerce.exceptions import TimeoutError, ApiError from commerce.tests import EcommerceApiTestMixin from course_modes.models import CourseMode @@ -22,7 +22,7 @@ from student.tests.tests import EnrollmentEventTestMixin @ddt @override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL, ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY) -class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase): +class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase): """ Tests for the commerce orders view. """ @@ -48,6 +48,11 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto actual = json.loads(response.content)['detail'] self.assertEqual(actual, expected_msg) + def assertResponsePaymentData(self, response): + """ Asserts correctness of a JSON body containing payment information. """ + actual_response = json.loads(response.content) + self.assertEqual(actual_response, self.PAYMENT_DATA) + def assertValidEcommerceInternalRequestErrorResponse(self, response): """ Asserts the response is a valid response sent when the E-Commerce API is unavailable. """ self.assertEqual(response.status_code, 500) @@ -60,8 +65,8 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto self.assert_no_events_were_emitted() def setUp(self): - super(OrdersViewTests, self).setUp() - self.url = reverse('commerce:orders') + super(BasketsViewTests, self).setUp() + self.url = reverse('commerce:baskets') self.user = UserFactory() self._login() @@ -113,7 +118,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto """ If the call to the E-Commerce API times out, the view should log an error and return an HTTP 503 status. """ - with self.mock_create_order(side_effect=TimeoutError): + with self.mock_create_basket(side_effect=TimeoutError): response = self._post_to_view() self.assertValidEcommerceInternalRequestErrorResponse(response) @@ -123,22 +128,24 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto """ If the E-Commerce API raises an error, the view should return an HTTP 503 status. """ - with self.mock_create_order(side_effect=ApiError): + with self.mock_create_basket(side_effect=ApiError): response = self._post_to_view() self.assertValidEcommerceInternalRequestErrorResponse(response) self.assertUserNotEnrolled() - def _test_successful_ecommerce_api_call(self): + def _test_successful_ecommerce_api_call(self, is_completed=True): """ Verifies that the view contacts the E-Commerce API with the correct data and headers. """ - with self.mock_create_order(): - response = self._post_to_view() + response = self._post_to_view() # Validate the response content - msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER) - self.assertResponseMessage(response, msg) + if is_completed: + msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER) + self.assertResponseMessage(response, msg) + else: + self.assertResponsePaymentData(response) @data(True, False) def test_course_with_honor_seat_sku(self, user_is_active): @@ -151,26 +158,30 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto self.user.is_active = user_is_active self.user.save() # pylint: disable=no-member - self._test_successful_ecommerce_api_call() + return_value = {'id': self.BASKET_ID, 'payment_data': None, 'order': {'number': self.ORDER_NUMBER}} + with self.mock_create_basket(return_value=return_value): + self._test_successful_ecommerce_api_call() - def test_order_not_complete(self): - with self.mock_create_order(return_value=(self.ORDER_NUMBER, - OrderStatus.OPEN, - self.ECOMMERCE_API_SUCCESSFUL_BODY)): - response = self._post_to_view() - self.assertEqual(response.status_code, 202) - msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=self.ORDER_NUMBER) - self.assertResponseMessage(response, msg) + @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 + will use to redirect the user to an external payment processor. + """ + # Set user's active flag + self.user.is_active = user_is_active + self.user.save() # pylint: disable=no-member - # TODO Eventually we should NOT be enrolling users directly from this view. - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + return_value = {'id': self.BASKET_ID, 'payment_data': self.PAYMENT_DATA, 'order': None} + with self.mock_create_basket(return_value=return_value): + self._test_successful_ecommerce_api_call(False) def _test_course_without_sku(self): """ Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs. """ # Place an order - with self.mock_create_order() as api_mock: + with self.mock_create_basket() as api_mock: response = self._post_to_view() # Validate the response content @@ -199,7 +210,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto """ If the E-Commerce Service is not configured, the view should enroll the user. """ - with self.mock_create_order() as api_mock: + with self.mock_create_basket() as api_mock: response = self._post_to_view() # Validate the response @@ -219,7 +230,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode, sku=uuid4().hex.decode('ascii')) - with self.mock_create_order() as api_mock: + with self.mock_create_basket() as api_mock: response = self._post_to_view() # The view should return an error status code @@ -274,4 +285,17 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) self.assertIsNotNone(get_enrollment(self.user.username, unicode(self.course.id))) - self._test_successful_ecommerce_api_call() + with self.mock_create_basket(): + self._test_successful_ecommerce_api_call(False) + + +class OrdersViewTests(BasketsViewTests): + """ + Ensures that /orders/ points to and behaves like /baskets/, for backward + compatibility with stale js clients during updates. + + (XCOM-214) remove after release. + """ + def setUp(self): + super(OrdersViewTests, self).setUp() + self.url = reverse('commerce:orders') diff --git a/lms/djangoapps/commerce/urls.py b/lms/djangoapps/commerce/urls.py index 711a69d44d..390b3dc67d 100644 --- a/lms/djangoapps/commerce/urls.py +++ b/lms/djangoapps/commerce/urls.py @@ -4,10 +4,12 @@ Defines the URL routes for this app. from django.conf.urls import patterns, url -from .views import OrdersView, checkout_cancel +from .views import BasketsView, checkout_cancel urlpatterns = patterns( '', - url(r'^orders/$', OrdersView.as_view(), name="orders"), + url(r'^baskets/$', BasketsView.as_view(), name="baskets"), url(r'^checkout/cancel/$', checkout_cancel, name="checkout_cancel"), + # (XCOM-214) For backwards compatibility with js clients during intial release + url(r'^orders/$', BasketsView.as_view(), name="orders"), ) diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index c80daf7229..8de4fbff8d 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -3,31 +3,31 @@ import logging from django.conf import settings from django.views.decorators.cache import cache_page - from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework.permissions import IsAuthenticated -from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_409_CONFLICT +from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT from rest_framework.views import APIView from commerce.api import EcommerceAPI -from commerce.constants import OrderStatus, Messages -from commerce.exceptions import ApiError, InvalidConfigurationError +from commerce.constants import Messages +from commerce.exceptions import ApiError, InvalidConfigurationError, InvalidResponseError from commerce.http import DetailResponse, InternalRequestErrorResponse from course_modes.models import CourseMode from courseware import courses from edxmako.shortcuts import render_to_response from enrollment.api import add_enrollment from microsite_configuration import microsite -from student.models import CourseEnrollment from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser +from student.models import CourseEnrollment +from util.json_request import JsonResponse log = logging.getLogger(__name__) -class OrdersView(APIView): - """ Creates an order with a course seat and enrolls users. """ +class BasketsView(APIView): + """ Creates a basket with a course seat and enrolls users. """ # LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh! authentication_classes = (SessionAuthenticationAllowInactiveUser,) @@ -63,7 +63,7 @@ class OrdersView(APIView): def post(self, request, *args, **kwargs): # pylint: disable=unused-argument """ - Attempt to create the order and enroll the user. + Attempt to create the basket and enroll the user. """ user = request.user valid, course_key, error = self._is_data_valid(request) @@ -103,28 +103,31 @@ class OrdersView(APIView): # Make the API call try: - order_number, order_status, _body = api.create_order(user, honor_mode.sku) - if order_status == OrderStatus.COMPLETE: - msg = Messages.ORDER_COMPLETED.format(order_number=order_number) + response_data = api.create_basket( + user, + honor_mode.sku, + payment_processor="cybersource", + ) + payment_data = response_data["payment_data"] + if payment_data is not None: + # it is time to start the payment flow. + # NOTE this branch does not appear to be used at the moment. + return JsonResponse(payment_data) + elif response_data['order']: + # the order was completed immediately because there was no charge. + msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number']) log.debug(msg) return DetailResponse(msg) else: - # TODO Before this functionality is fully rolled-out, this branch should be updated to NOT enroll the - # user. Enrollments must be initiated by the E-Commerce API only. + # Enroll in the honor mode directly as a failsafe. + # This MUST be removed when this code handles paid modes. self._enroll(course_key, user) - msg = u'Order %(order_number)s was received with %(status)s status. Expected %(complete_status)s. ' \ - u'User %(username)s was enrolled in %(course_id)s by LMS.' - msg_kwargs = { - 'order_number': order_number, - 'status': order_status, - 'complete_status': OrderStatus.COMPLETE, - 'username': user.username, - 'course_id': course_id, - } - log.error(msg, msg_kwargs) - - msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number) - return DetailResponse(msg, status=HTTP_202_ACCEPTED) + msg = u'Unexpected response from basket endpoint.' + log.error( + msg + u' Could not enroll user %(username)s in course %(course_id)s.', + {'username': user.id, 'course_id': course_id}, + ) + raise InvalidResponseError(msg) except ApiError as err: # The API will handle logging of the error. return InternalRequestErrorResponse(err.message) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 9976ff8f10..dc254cd620 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -918,24 +918,27 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): """Mocks calls to EcommerceAPI.get_order. """ patch = None - ORDER = copy.deepcopy(EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY) - ORDER['total_excl_tax'] = 40.0 - ORDER['currency'] = 'USD' - ORDER['sources'] = [{'transactions': [ - {'date_created': '2015-04-07 17:59:06.274587+00:00'}, - {'date_created': '2015-04-08 13:33:06.150000+00:00'}, - {'date_created': '2015-04-09 10:45:06.200000+00:00'}, - ]}] - ORDER['billing_address'] = { - 'first_name': 'Philip', - 'last_name': 'Fry', - 'line1': 'Robot Arms Apts', - 'line2': '22 Robot Street', - 'line4': 'New New York', - 'state': 'NY', - 'postcode': '11201', - 'country': { - 'display_name': 'United States', + ORDER = { + 'status': OrderStatus.COMPLETE, + 'number': EcommerceApiTestMixin.ORDER_NUMBER, + 'total_excl_tax': 40.0, + 'currency': 'USD', + 'sources': [{'transactions': [ + {'date_created': '2015-04-07 17:59:06.274587+00:00'}, + {'date_created': '2015-04-08 13:33:06.150000+00:00'}, + {'date_created': '2015-04-09 10:45:06.200000+00:00'}, + ]}], + 'billing_address': { + 'first_name': 'Philip', + 'last_name': 'Fry', + 'line1': 'Robot Arms Apts', + 'line2': '22 Robot Street', + 'line4': 'New New York', + 'state': 'NY', + 'postcode': '11201', + 'country': { + 'display_name': 'United States', + }, }, } diff --git a/lms/djangoapps/verify_student/tests/test_integration.py b/lms/djangoapps/verify_student/tests/test_integration.py index 9a50df7c78..e792425080 100644 --- a/lms/djangoapps/verify_student/tests/test_integration.py +++ b/lms/djangoapps/verify_student/tests/test_integration.py @@ -58,4 +58,4 @@ class TestProfEdVerification(ModuleStoreTestCase): # On the first page of the flow, verify that there's a button allowing the user # to proceed to the payment processor; this is the only action the user is allowed to take. - self.assertContains(resp, 'pay_button') + self.assertContains(resp, 'payment-button') diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 3cb81a82e7..ea1e3ce4b5 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -52,6 +52,8 @@ def mock_render_to_response(*args, **kwargs): render_mock = Mock(side_effect=mock_render_to_response) +PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'} + class StartView(TestCase): def start_url(self, course_id=""): @@ -849,166 +851,159 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): self.assertEqual(response_dict['course_name'], mode_display_name) -class TestCreateOrder(EcommerceApiTestMixin, ModuleStoreTestCase): +class CheckoutTestMixin(object): """ - Tests for the create order view. + Mixin implementing test methods that should behave identically regardless + of which backend is used (shoppingcart or ecommerce service). Subclasses + immediately follow for each backend, which inherit from TestCase and + define methods needed to customize test parameters, and patch the + appropriate checkout method. + + Though the view endpoint under test is named 'create_order' for backward- + compatibility, the effect of using this endpoint is to choose a specific product + (i.e. course mode) and trigger immediate checkout. """ def setUp(self): """ Create a user and course. """ - super(TestCreateOrder, self).setUp() + super(CheckoutTestMixin, self).setUp() self.user = UserFactory.create(username="test", password="test") self.course = CourseFactory.create() for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)): - # Set SKU to empty string to ensure view knows how to handle such values - CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku='') + CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku=self.make_sku()) self.client.login(username="test", password="test") - def _post(self, data): + def _assert_checked_out( + self, + post_params, + patched_create_order, + expected_course_key, + expected_mode_slug, + expected_status_code=200 + ): """ - POST to the view being tested and return the response. + DRY helper. + + Ensures that checkout functions were invoked as + expected during execution of the create_order endpoint. """ - url = reverse('verify_student_create_order') - return self.client.post(url, data) - - def test_create_order_already_verified(self): - # Verify the student so we don't need to submit photos - self._verify_student() + post_params.setdefault('processor', None) + response = self.client.post(reverse('verify_student_create_order'), post_params) + self.assertEqual(response.status_code, expected_status_code) + if expected_status_code == 200: + # ensure we called checkout at all + self.assertTrue(patched_create_order.called) + # ensure checkout args were correct + args = self._get_checkout_args(patched_create_order) + self.assertEqual(args['user'], self.user) + self.assertEqual(args['course_key'], expected_course_key) + self.assertEqual(args['course_mode'].slug, expected_mode_slug) + # ensure response data was correct + data = json.loads(response.content) + self.assertEqual(set(data.keys()), PAYMENT_DATA_KEYS) + else: + self.assertFalse(patched_create_order.called) + def test_create_order(self, patched_create_order): # Create an order params = { 'course_id': unicode(self.course.id), - 'contribution': 100 + 'contribution': 100, } - response = self._post(params) - self.assertEqual(response.status_code, 200) - - # Verify that the information will be sent to the correct callback URL - # (configured by test settings) - data = json.loads(response.content) - self.assertEqual(data['override_custom_receipt_page'], "http://testserver/shoppingcart/postpay_callback/") - - # Verify that the course ID and transaction type are included in "merchant-defined data" - self.assertEqual(data['merchant_defined_data1'], unicode(self.course.id)) - self.assertEqual(data['merchant_defined_data2'], "verified") - - def test_create_order_already_verified_prof_ed(self): - # Verify the student so we don't need to submit photos - self._verify_student() + self._assert_checked_out(params, patched_create_order, self.course.id, 'verified') + def test_create_order_prof_ed(self, patched_create_order): # Create a prof ed course course = CourseFactory.create() - CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) - + CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10, sku=self.make_sku()) # Create an order for a prof ed course params = {'course_id': unicode(course.id)} - response = self._post(params) - self.assertEqual(response.status_code, 200) - - # Verify that the course ID and transaction type are included in "merchant-defined data" - data = json.loads(response.content) - self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) - self.assertEqual(data['merchant_defined_data2'], "professional") - - def test_create_order_for_no_id_professional(self): + self._assert_checked_out(params, patched_create_order, course.id, 'professional') + def test_create_order_no_id_professional(self, patched_create_order): # Create a no-id-professional ed course course = CourseFactory.create() - CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10) - + CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10, sku=self.make_sku()) # Create an order for a prof ed course params = {'course_id': unicode(course.id)} - response = self._post(params) - self.assertEqual(response.status_code, 200) - - # Verify that the course ID and transaction type are included in "merchant-defined data" - data = json.loads(response.content) - self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) - self.assertEqual(data['merchant_defined_data2'], "no-id-professional") - - def test_create_order_for_multiple_paid_modes(self): + self._assert_checked_out(params, patched_create_order, course.id, 'no-id-professional') + def test_create_order_for_multiple_paid_modes(self, patched_create_order): # Create a no-id-professional ed course course = CourseFactory.create() - CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10) - CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) - + CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10, sku=self.make_sku()) + CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10, sku=self.make_sku()) # Create an order for a prof ed course params = {'course_id': unicode(course.id)} - response = self._post(params) - self.assertEqual(response.status_code, 200) - - # Verify that the course ID and transaction type are included in "merchant-defined data" - data = json.loads(response.content) - self.assertEqual(data['merchant_defined_data1'], unicode(course.id)) - self.assertEqual(data['merchant_defined_data2'], "no-id-professional") - - def test_create_order_set_donation_amount(self): - # Verify the student so we don't need to submit photos - self._verify_student() + # TODO jsa - is this the intended behavior? + self._assert_checked_out(params, patched_create_order, course.id, 'no-id-professional') + def test_create_order_bad_donation_amount(self, patched_create_order): # Create an order params = { 'course_id': unicode(self.course.id), - 'contribution': '1.23' + 'contribution': '99.9' } - self._post(params) + self._assert_checked_out(params, patched_create_order, None, None, expected_status_code=400) - # Verify that the client's session contains the new donation amount - self.assertNotIn('donation_for_course', self.client.session) + def test_create_order_good_donation_amount(self, patched_create_order): + # Create an order + params = { + 'course_id': unicode(self.course.id), + 'contribution': '100.0' + } + self._assert_checked_out(params, patched_create_order, self.course.id, 'verified') - def _verify_student(self): - """ Simulate that the student's identity has already been verified. """ - attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) - attempt.mark_ready() - attempt.submit() - attempt.approve() - - @override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL, - ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY) - def test_create_order_with_ecommerce_api(self): - """ Verifies that the view communicates with the E-Commerce API to create orders. """ - # Keep track of the original number of orders to verify the old code is not being called. - order_count = Order.objects.count() - - # Add SKU to CourseModes - for course_mode in CourseMode.objects.filter(course_id=self.course.id): - course_mode.sku = uuid4().hex.decode('ascii') - course_mode.save() - - # Mock the E-Commerce Service response - with self.mock_create_order(): - self._verify_student() - params = {'course_id': unicode(self.course.id), 'contribution': 100} - response = self._post(params) - - # Verify the response is correct. + def test_old_clients(self, patched_create_order): + # ensure the response to a request from a stale js client is modified so as + # not to break behavior in the browser. + # (XCOM-214) remove after release. + expected_payment_data = EcommerceApiTestMixin.PAYMENT_DATA.copy() + expected_payment_data['payment_form_data'].update({'foo': 'bar'}) + patched_create_order.return_value = expected_payment_data + # there is no 'processor' parameter in the post payload, so the response should only contain payment form data. + params = {'course_id': unicode(self.course.id), 'contribution': 100} + response = self.client.post(reverse('verify_student_create_order'), params) self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'], 'application/json') - self.assertEqual(json.loads(response.content), self.ECOMMERCE_API_SUCCESSFUL_BODY['payment_parameters']) + self.assertTrue(patched_create_order.called) + # ensure checkout args were correct + args = self._get_checkout_args(patched_create_order) + self.assertEqual(args['user'], self.user) + self.assertEqual(args['course_key'], self.course.id) + self.assertEqual(args['course_mode'].slug, 'verified') + # ensure response data was correct + data = json.loads(response.content) + self.assertEqual(data, {'foo': 'bar'}) - # Verify old code is not called (e.g. no Order object created in LMS) - self.assertEqual(order_count, Order.objects.count()) - def _add_course_mode_skus(self): - """ Add SKUs to the CourseMode objects for self.course. """ - for course_mode in CourseMode.objects.filter(course_id=self.course.id): - course_mode.sku = uuid4().hex.decode('ascii') - course_mode.save() +@patch('verify_student.views.checkout_with_shoppingcart', return_value=EcommerceApiTestMixin.PAYMENT_DATA) +class TestCreateOrderShoppingCart(CheckoutTestMixin, ModuleStoreTestCase): + """ Test view behavior when the shoppingcart is used. """ - @override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL, - ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY) - def test_create_order_with_ecommerce_api_errors(self): - """ - Verifies that the view communicates with the E-Commerce API to create orders, and handles errors - appropriately. - """ - self._add_course_mode_skus() + def make_sku(self): + """ Checkout is handled by shoppingcart when the course mode's sku is empty. """ + return '' - with self.mock_create_order(side_effect=ApiError): - self._verify_student() - params = {'course_id': unicode(self.course.id), 'contribution': 100} - self.assertRaises(ApiError, self._post, params) + def _get_checkout_args(self, patched_create_order): + """ Assuming patched_create_order was called, return a mapping containing the call arguments.""" + return dict(zip(('request', 'user', 'course_key', 'course_mode', 'amount'), patched_create_order.call_args[0])) + + +@override_settings( + ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL, + ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY +) +@patch('verify_student.views.checkout_with_ecommerce_service', return_value=EcommerceApiTestMixin.PAYMENT_DATA) +class TestCreateOrderEcommerceService(CheckoutTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase): + """ Test view behavior when the ecommerce service is used. """ + + def make_sku(self): + """ Checkout is handled by the ecommerce service when the course mode's sku is nonempty. """ + return uuid4().hex.decode('ascii') + + def _get_checkout_args(self, patched_create_order): + """ Assuming patched_create_order was called, return a mapping containing the call arguments.""" + return dict(zip(('user', 'course_key', 'course_mode', 'processor'), patched_create_order.call_args[0])) class TestCreateOrderView(ModuleStoreTestCase): @@ -1099,7 +1094,7 @@ class TestCreateOrderView(ModuleStoreTestCase): photo_id_image=self.IMAGE_DATA ) json_response = json.loads(response.content) - self.assertIsNotNone(json_response.get('orderNumber')) + self.assertIsNotNone(json_response['payment_form_data'].get('orderNumber')) # TODO not canonical # Verify that the order exists and is configured correctly order = Order.objects.get(user=self.user) @@ -1135,7 +1130,8 @@ class TestCreateOrderView(ModuleStoreTestCase): url = reverse('verify_student_create_order') data = { 'contribution': contribution, - 'course_id': course_id + 'course_id': course_id, + 'processor': None, } if face_image is not None: @@ -1149,9 +1145,9 @@ class TestCreateOrderView(ModuleStoreTestCase): if expect_status_code == 200: json_response = json.loads(response.content) if expect_success: - self.assertTrue(json_response.get('success')) + self.assertEqual(set(json_response.keys()), PAYMENT_DATA_KEYS) else: - self.assertFalse(json_response.get('success')) + self.assertFalse(json_response['success']) return response diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index b2f98de1fb..60c10ac692 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -380,6 +380,14 @@ class PayAndVerifyView(View): # Determine the photo verification status verification_good_until = self._verification_valid_until(request.user) + # get available payment processors + if unexpired_paid_course_mode.sku: + # transaction will be conducted via ecommerce service + processors = EcommerceAPI().get_processors(request.user) + else: + # transaction will be conducted using legacy shopping cart + processors = [settings.CC_PROCESSOR_NAME] + # Render the top-level page context = { 'contribution_amount': contribution_amount, @@ -393,7 +401,7 @@ class PayAndVerifyView(View): 'is_active': json.dumps(request.user.is_active), 'message_key': message, 'platform_name': settings.PLATFORM_NAME, - 'purchase_endpoint': get_purchase_endpoint(), + 'processors': processors, 'requirements': requirements, 'user_full_name': full_name, 'verification_deadline': ( @@ -644,26 +652,59 @@ class PayAndVerifyView(View): return (has_paid, bool(is_active)) -def create_order_with_ecommerce_service(user, course_key, course_mode): # pylint: disable=invalid-name - """ Create a new order using the E-Commerce API. """ +def checkout_with_ecommerce_service(user, course_key, course_mode, processor): # pylint: disable=invalid-name + """ Create a new basket and trigger immediate checkout, using the E-Commerce API. """ try: api = EcommerceAPI() # Make an API call to create the order and retrieve the results - _order_number, _order_status, data = api.create_order(user, course_mode.sku) - + response_data = api.create_basket(user, course_mode.sku, processor) # Pass the payment parameters directly from the API response. - return HttpResponse(json.dumps(data['payment_parameters']), content_type='application/json') + return response_data.get('payment_data') except ApiError: params = {'username': user.username, 'mode': course_mode.slug, 'course_id': unicode(course_key)} log.error('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params) raise +def checkout_with_shoppingcart(request, user, course_key, course_mode, amount): + """ Create an order and trigger checkout using shoppingcart.""" + cart = Order.get_cart_for_user(user) + cart.clear() + enrollment_mode = course_mode.slug + CertificateItem.add_to_order(cart, course_key, amount, enrollment_mode) + + # Change the order's status so that we don't accidentally modify it later. + # We need to do this to ensure that the parameters we send to the payment system + # match what we store in the database. + # (Ordinarily we would do this client-side when the user submits the form, but since + # the JavaScript on this page does that immediately, we make the change here instead. + # This avoids a second AJAX call and some additional complication of the JavaScript.) + # If a user later re-enters the verification / payment flow, she will create a new order. + cart.start_purchase() + + callback_url = request.build_absolute_uri( + reverse("shoppingcart.views.postpay_callback") + ) + + payment_data = { + 'payment_processor_name': settings.CC_PROCESSOR_NAME, + 'payment_page_url': get_purchase_endpoint(), + 'payment_form_data': get_signed_purchase_params( + cart, + callback_url=callback_url, + extra_data=[unicode(course_key), course_mode.slug] + ), + } + return payment_data + + @require_POST @login_required def create_order(request): """ - Submit PhotoVerification and create a new Order for this verified cert + This endpoint is named 'create_order' for backward compatibility, but its + actual use is to add a single product to the user's cart and request + immediate checkout. """ # Only submit photos if photo data is provided by the client. # TODO (ECOM-188): Once the A/B test of decoupling verified / payment @@ -724,35 +765,23 @@ def create_order(request): return HttpResponseBadRequest(_("No selected price or selected price is below minimum.")) if current_mode.sku: - return create_order_with_ecommerce_service(request.user, course_id, current_mode) + # if request.POST doesn't contain 'processor' then the service's default payment processor will be used. + payment_data = checkout_with_ecommerce_service( + request.user, + course_id, + current_mode, + request.POST.get('processor') + ) + else: + payment_data = checkout_with_shoppingcart(request, request.user, course_id, current_mode, amount) - # I know, we should check this is valid. All kinds of stuff missing here - cart = Order.get_cart_for_user(request.user) - cart.clear() - enrollment_mode = current_mode.slug - CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode) - - # Change the order's status so that we don't accidentally modify it later. - # We need to do this to ensure that the parameters we send to the payment system - # match what we store in the database. - # (Ordinarily we would do this client-side when the user submits the form, but since - # the JavaScript on this page does that immediately, we make the change here instead. - # This avoids a second AJAX call and some additional complication of the JavaScript.) - # If a user later re-enters the verification / payment flow, she will create a new order. - cart.start_purchase() - - callback_url = request.build_absolute_uri( - reverse("shoppingcart.views.postpay_callback") - ) - - params = get_signed_purchase_params( - cart, - callback_url=callback_url, - extra_data=[unicode(course_id), current_mode.slug] - ) - - params['success'] = True - return HttpResponse(json.dumps(params), content_type="text/json") + if 'processor' not in request.POST: + # (XCOM-214) To be removed after release. + # the absence of this key in the POST payload indicates that the request was initiated from + # a stale js client, which expects a response containing only the 'payment_form_data' part of + # the payment data result. + payment_data = payment_data['payment_form_data'] + return HttpResponse(json.dumps(payment_data), content_type="application/json") @require_POST diff --git a/lms/static/js/spec/photocapture_spec.js b/lms/static/js/spec/photocapture_spec.js index da0826a896..85ec82ce75 100644 --- a/lms/static/js/spec/photocapture_spec.js +++ b/lms/static/js/spec/photocapture_spec.js @@ -4,7 +4,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], describe("Photo Verification", function () { beforeEach(function () { - setFixtures(''); + setFixtures(''); }); it('retake photo', function () { @@ -27,7 +27,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], }); submitToPaymentProcessing(); expect(window.submitForm).toHaveBeenCalled(); - expect($("#pay_button")).toHaveClass("is-disabled"); + expect($(".payment-button")).toHaveClass("is-disabled"); }); it('Error during process', function () { @@ -44,7 +44,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], expect(window.showSubmissionError).toHaveBeenCalled(); // make sure the button isn't disabled - expect($("#pay_button")).not.toHaveClass("is-disabled"); + expect($(".payment-button")).not.toHaveClass("is-disabled"); // but also make sure that it was disabled during the ajax call expect($.fn.addClass).toHaveBeenCalledWith("is-disabled"); diff --git a/lms/static/js/spec/student_account/enrollment_spec.js b/lms/static/js/spec/student_account/enrollment_spec.js index cb8ab745af..742b027114 100644 --- a/lms/static/js/spec/student_account/enrollment_spec.js +++ b/lms/static/js/spec/student_account/enrollment_spec.js @@ -5,7 +5,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'], describe( 'edx.student.account.EnrollmentInterface', function() { var COURSE_KEY = 'edX/DemoX/Fall', - ENROLL_URL = '/commerce/orders/', + ENROLL_URL = '/commerce/baskets/', FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/', EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/'; diff --git a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js index fa90b016e3..1ec453cee2 100644 --- a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js +++ b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js @@ -11,8 +11,6 @@ define([ describe( 'edx.verify_student.MakePaymentStepView', function() { - var PAYMENT_URL = "/pay"; - var PAYMENT_PARAMS = { orderId: "test-order", signature: "abcd1234" @@ -21,7 +19,7 @@ define([ var STEP_DATA = { minPrice: "12", currency: "usd", - purchaseEndpoint: PAYMENT_URL, + processors: ["test-payment-processor"], courseKey: "edx/test/test", courseModeSlug: 'verified' }; @@ -50,15 +48,16 @@ define([ }; var expectPaymentButtonEnabled = function( isEnabled ) { - var appearsDisabled = $( '#pay_button' ).hasClass( 'is-disabled' ), - isDisabled = $( '#pay_button' ).prop( 'disabled' ); + var el = $( '.payment-button'), + appearsDisabled = el.hasClass( 'is-disabled' ), + isDisabled = el.prop( 'disabled' ); expect( !appearsDisabled ).toEqual( isEnabled ); expect( !isDisabled ).toEqual( isEnabled ); }; var expectPaymentDisabledBecauseInactive = function() { - var payButton = $( '#pay_button' ); + var payButton = $( '.payment_button' ); // Payment button should be hidden expect( payButton.length ).toEqual(0); @@ -67,21 +66,22 @@ define([ var goToPayment = function( requests, kwargs ) { var params = { contribution: kwargs.amount || "", - course_id: kwargs.courseId || "" + course_id: kwargs.courseId || "", + processor: kwargs.processor || "" }; // Click the "go to payment" button - $( '#pay_button' ).click(); + $( '.payment-button' ).click(); // Verify that the request was made to the server - AjaxHelpers.expectRequest( - requests, "POST", "/verify_student/create_order/", - $.param( params ) + AjaxHelpers.expectPostRequest( + requests, "/verify_student/create_order/", $.param( params ) ); // Simulate the server response if ( kwargs.succeeds ) { - AjaxHelpers.respondWithJson( requests, PAYMENT_PARAMS ); + // TODO put fixture responses in the right place + AjaxHelpers.respondWithJson( requests, {payment_page_url: 'http://payment-page-url/', payment_form_data: {foo: 'bar'}} ); } else { AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG ); } @@ -95,7 +95,7 @@ define([ expect(form.serialize()).toEqual($.param(params)); expect(form.attr('method')).toEqual("POST"); - expect(form.attr('action')).toEqual(PAYMENT_URL); + expect(form.attr('action')).toEqual('http://payment-page-url/'); }; beforeEach(function() { @@ -114,9 +114,10 @@ define([ goToPayment( requests, { amount: STEP_DATA.minPrice, courseId: STEP_DATA.courseKey, + processor: STEP_DATA.processors[0], succeeds: true }); - expectPaymentSubmitted( view, PAYMENT_PARAMS ); + expectPaymentSubmitted( view, {foo: 'bar'} ); }); it( 'by default minimum price is selected if no suggested prices are given', function() { @@ -129,9 +130,10 @@ define([ goToPayment( requests, { amount: STEP_DATA.minPrice, courseId: STEP_DATA.courseKey, + processor: STEP_DATA.processors[0], succeeds: true }); - expectPaymentSubmitted( view, PAYMENT_PARAMS ); + expectPaymentSubmitted( view, {foo: 'bar'} ); }); it( 'min price is always selected even if contribution amount is provided', function() { @@ -156,6 +158,7 @@ define([ goToPayment( requests, { amount: STEP_DATA.minPrice, courseId: STEP_DATA.courseKey, + processor: STEP_DATA.processors[0], succeeds: false }); diff --git a/lms/static/js/student_account/enrollment.js b/lms/static/js/student_account/enrollment.js index dbaaf6a1c3..6374e2f3e4 100644 --- a/lms/static/js/student_account/enrollment.js +++ b/lms/static/js/student_account/enrollment.js @@ -9,7 +9,7 @@ var edx = edx || {}; edx.student.account.EnrollmentInterface = { urls: { - orders: '/commerce/orders/', + baskets: '/commerce/baskets/', }, headers: { @@ -26,7 +26,7 @@ var edx = edx || {}; data = JSON.stringify(data_obj); $.ajax({ - url: this.urls.orders, + url: this.urls.baskets, type: 'POST', contentType: 'application/json; charset=utf-8', data: data, diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js index c7a8a8b043..0264d6d4c7 100644 --- a/lms/static/js/verify_student/pay_and_verify.js +++ b/lms/static/js/verify_student/pay_and_verify.js @@ -59,7 +59,7 @@ var edx = edx || {}; function( price ) { return Boolean( price ); } ), currency: el.data('course-mode-currency'), - purchaseEndpoint: el.data('purchase-endpoint'), + processors: el.data('processors'), verificationDeadline: el.data('verification-deadline'), courseModeSlug: el.data('course-mode-slug'), alreadyVerified: el.data('already-verified'), diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 4d4780a5b9..7d3716238c 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -69,7 +69,7 @@ function refereshPageMessage() { } var submitToPaymentProcessing = function() { - $("#pay_button").addClass('is-disabled').attr('aria-disabled', true); + $(".payment-button").addClass('is-disabled').attr('aria-disabled', true); var contribution_input = $("input[name='contribution']:checked") var contribution = 0; if(contribution_input.attr('id') == 'contribution-other') { @@ -96,7 +96,7 @@ var submitToPaymentProcessing = function() { } }, error:function(xhr,status,error) { - $("#pay_button").removeClass('is-disabled').attr('aria-disabled', false); + $(".payment-button").removeClass('is-disabled').attr('aria-disabled', false); showSubmissionError() } }); @@ -290,7 +290,7 @@ function waitForFlashLoad(func, flash_object) { $(document).ready(function() { $(".carousel-nav").addClass('sr'); - $("#pay_button").click(function(){ + $(".payment-button").click(function(){ analytics.pageview("Payment Form"); submitToPaymentProcessing(); }); @@ -306,7 +306,7 @@ $(document).ready(function() { // prevent browsers from keeping this button checked $("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").change(function() { - $("#pay_button").toggleClass('disabled'); + $(".payment-button").toggleClass('disabled'); $("#reverify_button").toggleClass('disabled'); $("#midcourse_reverify_button").toggleClass('disabled'); }); diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js index 0dd80092b4..4d7904f752 100644 --- a/lms/static/js/verify_student/views/make_payment_step_view.js +++ b/lms/static/js/verify_student/views/make_payment_step_view.js @@ -3,7 +3,7 @@ */ var edx = edx || {}; -(function( $, _, gettext ) { +(function( $, _, gettext, interpolate_text ) { 'use strict'; edx.verify_student = edx.verify_student || {}; @@ -28,12 +28,45 @@ var edx = edx || {}; }; }, + _getProductText: function( modeSlug, isUpgrade ) { + switch ( modeSlug ) { + case "professional": + return gettext( "Professional Education Verified Certificate" ); + case "no-id-professional": + return gettext( "Professional Education" ); + default: + if ( isUpgrade ) { + return gettext( "Verified Certificate upgrade" ); + } else { + return gettext( "Verified Certificate" ); + } + } + }, + + _getPaymentButtonText: function(processorName) { + if (processorName.toLowerCase().substr(0, 11)=='cybersource') { + return gettext('Pay with Credit Card'); + } else { + // This is mainly for testing as no other processors are supported right now. + // Translators: 'processor' is the name of a third-party payment processing vendor (example: "PayPal") + return interpolate_text(gettext('Pay with {processor}'), {processor: processorName}); + } + }, + + _getPaymentButtonHtml: function(processorName) { + var self = this; + return _.template( + ' ' + )({name: processorName, text: self._getPaymentButtonText(processorName)}); + }, + postRender: function() { var templateContext = this.templateContext(), hasVisibleReqs = _.some( templateContext.requirements, function( isVisible ) { return isVisible; } - ); + ), + self = this; // Track a virtual pageview, for easy funnel reconstruction. window.analytics.page( 'payment', this.templateName ); @@ -59,25 +92,41 @@ var edx = edx || {}; this.setPaymentEnabled( true ); } + // render the name of the product being paid for + $( 'div.payment-buttons span.product-name').append( + self._getProductText( templateContext.courseModeSlug, templateContext.upgrade ) + ); + + // create a button for each payment processor + _.each(templateContext.processors, function(processorName) { + $( 'div.payment-buttons' ).append( self._getPaymentButtonHtml(processorName) ); + }); + // Handle payment submission - $( '#pay_button' ).on( 'click', _.bind( this.createOrder, this ) ); + $( '.payment-button' ).on( 'click', _.bind( this.createOrder, this ) ); }, setPaymentEnabled: function( isEnabled ) { if ( _.isUndefined( isEnabled ) ) { isEnabled = true; } - $( '#pay_button' ) + $( '.payment-button' ) .toggleClass( 'is-disabled', !isEnabled ) .prop( 'disabled', !isEnabled ) .attr('aria-disabled', !isEnabled); }, - createOrder: function() { + // This function invokes the create_order endpoint. It will either create an order in + // the lms' shoppingcart or a basket in Otto, depending on which backend the request course + // mode is configured to use. In either case, the checkout process will be triggered, + // and the expected response will consist of an appropriate payment processor endpoint for + // redirection, along with parameters to be passed along in the request. + createOrder: function(event) { var paymentAmount = this.getPaymentAmount(), postData = { + 'processor': event.target.id, 'contribution': paymentAmount, - 'course_id': this.stepData.courseKey, + 'course_id': this.stepData.courseKey }; // Disable the payment button to prevent multiple submissions @@ -98,21 +147,21 @@ var edx = edx || {}; }, - handleCreateOrderResponse: function( paymentParams ) { - // At this point, the order has been created on the server, + handleCreateOrderResponse: function( paymentData ) { + // At this point, the basket has been created on the server, // and we've received signed payment parameters. // We need to dynamically construct a form using // these parameters, then submit it to the payment processor. - // This will send the user to a hosted order page, - // where she can enter credit card information. + // This will send the user to an externally-hosted page + // where she can proceed with payment. var form = $( '#payment-processor-form' ); $( 'input', form ).remove(); - form.attr( 'action', this.stepData.purchaseEndpoint ); + form.attr( 'action', paymentData.payment_page_url ); form.attr( 'method', 'POST' ); - _.each( paymentParams, function( value, key ) { + _.each( paymentData.payment_form_data, function( value, key ) { $('').attr({ type: 'hidden', name: key, @@ -200,4 +249,4 @@ var edx = edx || {}; }); -})( jQuery, _, gettext ); +})( jQuery, _, gettext, interpolate_text ); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 434add48ae..cdfcd804f9 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -50,3 +50,22 @@ padding: ($baseline*1.5) $baseline; text-align: center; } + +// for verify_student/make_payment_step.underscore +.payment-buttons { + + .purchase { + float: left; + padding: ($baseline*.5) 0; + + .product-info, .product-name, .price { + @extend %t-ultrastrong; + color: $m-blue-d3; + } + } + + .payment-button { + float: right; + @include margin-left( ($baseline/2) ); + } +} diff --git a/lms/templates/verify_student/make_payment_step.underscore b/lms/templates/verify_student/make_payment_step.underscore index 2c7260cd4d..f4e29a2c5d 100644 --- a/lms/templates/verify_student/make_payment_step.underscore +++ b/lms/templates/verify_student/make_payment_step.underscore @@ -98,11 +98,16 @@ <% } %> <% if ( isActive ) { %> -