Commit 70d837ab by Jim Abramson

Merge pull request #7777 from edx/jsa/baskets

use commerce api v2.
parents 323e466e efde11d5
define(['sinon', 'underscore'], function(sinon, _) { define(['sinon', 'underscore'], function(sinon, _) {
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest,
respondWithJson, respondWithError, respondWithTextError, responseWithNoContent; respondWithJson, respondWithError, respondWithTextError, respondWithNoContent;
/* These utility methods are used by Jasmine tests to create a mock server or /* 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 * get reference to mock requests. In either case, the cleanup (restore) is done with
...@@ -68,6 +68,20 @@ define(['sinon', 'underscore'], function(sinon, _) { ...@@ -68,6 +68,20 @@ define(['sinon', 'underscore'], function(sinon, _) {
expect(JSON.parse(request.requestBody)).toEqual(jsonRequest); 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) { respondWithJson = function(requests, jsonResponse, requestIndex) {
if (_.isUndefined(requestIndex)) { if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1; requestIndex = requests.length - 1;
...@@ -122,6 +136,7 @@ define(['sinon', 'underscore'], function(sinon, _) { ...@@ -122,6 +136,7 @@ define(['sinon', 'underscore'], function(sinon, _) {
'requests': fakeRequests, 'requests': fakeRequests,
'expectRequest': expectRequest, 'expectRequest': expectRequest,
'expectJsonRequest': expectJsonRequest, 'expectJsonRequest': expectJsonRequest,
'expectPostRequest': expectPostRequest,
'respondWithJson': respondWithJson, 'respondWithJson': respondWithJson,
'respondWithError': respondWithError, 'respondWithError': respondWithError,
'respondWithTextError': respondWithTextError, 'respondWithTextError': respondWithTextError,
......
...@@ -76,7 +76,7 @@ class PaymentAndVerificationFlow(PageObject): ...@@ -76,7 +76,7 @@ class PaymentAndVerificationFlow(PageObject):
def proceed_to_payment(self): def proceed_to_payment(self):
"""Interact with the payment button.""" """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() FakePaymentPage(self.browser, self._course_id).wait_for_page()
......
...@@ -60,26 +60,57 @@ class EcommerceAPI(object): ...@@ -60,26 +60,57 @@ class EcommerceAPI(object):
} }
url = '{base_url}/orders/{order_number}/'.format(base_url=self.url, order_number=order_number) 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 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 Returns a list of strings.
user -- User for which the order should be created. """
sku -- SKU of the course seat being ordered. def get():
"""Internal service call to retrieve the processor list. """
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user))
}
url = '{base_url}/payment/processors/'.format(base_url=self.url)
return requests.get(url, headers=headers, timeout=self.timeout)
Returns a tuple with the order number, order status, API response data. 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(): def create():
"""Internal service call to create an order. """ """Internal service call to create a basket. """
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user)) 'Authorization': 'JWT {}'.format(self._get_jwt(user))
} }
url = '{}/orders/'.format(self.url) url = '{}/baskets/'.format(self.url)
return requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout) 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) return self._call_ecommerce_service(create)
@staticmethod @staticmethod
...@@ -92,7 +123,7 @@ class EcommerceAPI(object): ...@@ -92,7 +123,7 @@ class EcommerceAPI(object):
Arguments Arguments
call -- A callable function that makes a request to the E-Commerce Service. 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: try:
response = call() response = call()
...@@ -109,7 +140,7 @@ class EcommerceAPI(object): ...@@ -109,7 +140,7 @@ class EcommerceAPI(object):
status_code = response.status_code status_code = response.status_code
if status_code == HTTP_200_OK: if status_code == HTTP_200_OK:
return data['number'], data['status'], data return data
else: else:
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s' msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
msg_kwargs = { msg_kwargs = {
......
...@@ -4,13 +4,8 @@ ...@@ -4,13 +4,8 @@
class OrderStatus(object): class OrderStatus(object):
"""Constants representing all known order statuses. """ """Constants representing all known order statuses. """
OPEN = 'Open' OPEN = 'Open'
ORDER_CANCELLED = 'Order Cancelled'
BEING_PROCESSED = 'Being Processed'
PAYMENT_CANCELLED = 'Payment Cancelled'
PAID = 'Paid'
FULFILLMENT_ERROR = 'Fulfillment Error' FULFILLMENT_ERROR = 'Fulfillment Error'
COMPLETE = 'Complete' COMPLETE = 'Complete'
REFUNDED = 'Refunded'
class Messages(object): class Messages(object):
......
...@@ -6,7 +6,6 @@ import jwt ...@@ -6,7 +6,6 @@ import jwt
import mock import mock
from commerce.api import EcommerceAPI from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus
class EcommerceApiTestMixin(object): class EcommerceApiTestMixin(object):
...@@ -14,12 +13,19 @@ class EcommerceApiTestMixin(object): ...@@ -14,12 +13,19 @@ class EcommerceApiTestMixin(object):
ECOMMERCE_API_URL = 'http://example.com/api' ECOMMERCE_API_URL = 'http://example.com/api'
ECOMMERCE_API_SIGNING_KEY = 'edx' ECOMMERCE_API_SIGNING_KEY = 'edx'
BASKET_ID = 7
ORDER_NUMBER = '100004' 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 = { ECOMMERCE_API_SUCCESSFUL_BODY = {
'status': OrderStatus.COMPLETE, 'id': BASKET_ID,
'number': ORDER_NUMBER, 'order': {'number': ORDER_NUMBER}, # never both None.
'payment_processor': 'cybersource', 'payment_data': PAYMENT_DATA,
'payment_parameters': {'orderNumber': ORDER_NUMBER}
} }
ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name
...@@ -28,14 +34,18 @@ class EcommerceApiTestMixin(object): ...@@ -28,14 +34,18 @@ class EcommerceApiTestMixin(object):
expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key) expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key)
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt)) 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. """ """ Verifies that an order request to the E-Commerce Service is valid. """
self.assertValidJWTAuthHeader(request, user, jwt_signing_key) self.assertValidJWTAuthHeader(request, user, jwt_signing_key)
expected_body_data = {
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku)) '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') 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. Mock calls to the E-Commerce API.
...@@ -43,27 +53,25 @@ class EcommerceApiTestMixin(object): ...@@ -43,27 +53,25 @@ class EcommerceApiTestMixin(object):
""" """
self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.') self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.')
url = self.ECOMMERCE_API_URL + '/orders/' url = self.ECOMMERCE_API_URL + '/baskets/'
body = body or self.ECOMMERCE_API_SUCCESSFUL_BODY_JSON 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) httpretty.register_uri(httpretty.POST, url, status=status, body=body)
class mock_create_order(object): # pylint: disable=invalid-name class mock_create_basket(object): # pylint: disable=invalid-name
""" Mocks calls to EcommerceAPI.create_order. """ """ Mocks calls to EcommerceAPI.create_basket. """
patch = None patch = None
def __init__(self, **kwargs): def __init__(self, **kwargs):
default_kwargs = { default_kwargs = {'return_value': EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY}
'return_value': (
EcommerceApiTestMixin.ORDER_NUMBER,
OrderStatus.COMPLETE,
EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY
)
}
default_kwargs.update(kwargs) default_kwargs.update(kwargs)
self.patch = mock.patch.object(EcommerceAPI, 'create_basket', mock.Mock(**default_kwargs))
self.patch = mock.patch.object(EcommerceAPI, 'create_order', mock.Mock(**default_kwargs))
def __enter__(self): def __enter__(self):
self.patch.start() self.patch.start()
......
...@@ -10,7 +10,6 @@ import httpretty ...@@ -10,7 +10,6 @@ import httpretty
from requests import Timeout from requests import Timeout
from commerce.api import EcommerceAPI from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
from commerce.tests import EcommerceApiTestMixin from commerce.tests import EcommerceApiTestMixin
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -26,7 +25,7 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): ...@@ -26,7 +25,7 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
def setUp(self): def setUp(self):
super(EcommerceAPITests, self).setUp() super(EcommerceAPITests, self).setUp()
self.url = reverse('commerce:orders') self.url = reverse('commerce:baskets')
self.user = UserFactory() self.user = UserFactory()
self.api = EcommerceAPI() self.api = EcommerceAPI()
...@@ -48,35 +47,40 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): ...@@ -48,35 +47,40 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
self.assertRaises(InvalidConfigurationError, EcommerceAPI) self.assertRaises(InvalidConfigurationError, EcommerceAPI)
@httpretty.activate @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. """ """ Verify the method makes a call to the E-Commerce API with the correct headers and data. """
self._mock_ecommerce_api() self._mock_ecommerce_api(is_payment_required=is_payment_required)
number, status, body = self.api.create_order(self.user, self.SKU) response_data = self.api.create_basket(self.user, self.SKU, self.PROCESSOR)
# Validate the request sent to the E-Commerce API endpoint. # Validate the request sent to the E-Commerce API endpoint.
request = httpretty.last_request() 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 # Validate the data returned by the method
self.assertEqual(number, self.ORDER_NUMBER) self.assertEqual(response_data['id'], self.BASKET_ID)
self.assertEqual(status, OrderStatus.COMPLETE) if is_payment_required:
self.assertEqual(body, self.ECOMMERCE_API_SUCCESSFUL_BODY) 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 @httpretty.activate
@data(400, 401, 405, 406, 429, 500, 503) @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. """ """ 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._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 @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. """ """ If the E-Commerce API returns un-parseable data, the method should raise an InvalidResponseError. """
self._mock_ecommerce_api(body='TOTALLY NOT JSON!') 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 @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. """ """ If the call to the E-Commerce API times out, the method should raise a TimeoutError. """
def request_callback(_request, _uri, _headers): def request_callback(_request, _uri, _headers):
...@@ -85,4 +89,4 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase): ...@@ -85,4 +89,4 @@ class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
self._mock_ecommerce_api(body=request_callback) 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)
...@@ -9,7 +9,7 @@ from django.test.utils import override_settings ...@@ -9,7 +9,7 @@ from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory 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.exceptions import TimeoutError, ApiError
from commerce.tests import EcommerceApiTestMixin from commerce.tests import EcommerceApiTestMixin
from course_modes.models import CourseMode from course_modes.models import CourseMode
...@@ -22,7 +22,7 @@ from student.tests.tests import EnrollmentEventTestMixin ...@@ -22,7 +22,7 @@ from student.tests.tests import EnrollmentEventTestMixin
@ddt @ddt
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL, @override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY) 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. Tests for the commerce orders view.
""" """
...@@ -48,6 +48,11 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -48,6 +48,11 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
actual = json.loads(response.content)['detail'] actual = json.loads(response.content)['detail']
self.assertEqual(actual, expected_msg) 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): def assertValidEcommerceInternalRequestErrorResponse(self, response):
""" Asserts the response is a valid response sent when the E-Commerce API is unavailable. """ """ Asserts the response is a valid response sent when the E-Commerce API is unavailable. """
self.assertEqual(response.status_code, 500) self.assertEqual(response.status_code, 500)
...@@ -60,8 +65,8 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -60,8 +65,8 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
self.assert_no_events_were_emitted() self.assert_no_events_were_emitted()
def setUp(self): def setUp(self):
super(OrdersViewTests, self).setUp() super(BasketsViewTests, self).setUp()
self.url = reverse('commerce:orders') self.url = reverse('commerce:baskets')
self.user = UserFactory() self.user = UserFactory()
self._login() self._login()
...@@ -113,7 +118,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -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. 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() response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response) self.assertValidEcommerceInternalRequestErrorResponse(response)
...@@ -123,22 +128,24 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -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. 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() response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response) self.assertValidEcommerceInternalRequestErrorResponse(response)
self.assertUserNotEnrolled() 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. 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 # Validate the response content
msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER) if is_completed:
self.assertResponseMessage(response, msg) msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER)
self.assertResponseMessage(response, msg)
else:
self.assertResponsePaymentData(response)
@data(True, False) @data(True, False)
def test_course_with_honor_seat_sku(self, user_is_active): def test_course_with_honor_seat_sku(self, user_is_active):
...@@ -151,26 +158,30 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -151,26 +158,30 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
self.user.is_active = user_is_active self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member 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): @data(True, False)
with self.mock_create_order(return_value=(self.ORDER_NUMBER, def test_course_with_paid_seat_sku(self, user_is_active):
OrderStatus.OPEN, """
self.ECOMMERCE_API_SUCCESSFUL_BODY)): If the course has a SKU, the view should return data that the client
response = self._post_to_view() will use to redirect the user to an external payment processor.
self.assertEqual(response.status_code, 202) """
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=self.ORDER_NUMBER) # Set user's active flag
self.assertResponseMessage(response, msg) 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. return_value = {'id': self.BASKET_ID, 'payment_data': self.PAYMENT_DATA, 'order': None}
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) with self.mock_create_basket(return_value=return_value):
self._test_successful_ecommerce_api_call(False)
def _test_course_without_sku(self): def _test_course_without_sku(self):
""" """
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs. Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
""" """
# Place an order # Place an order
with self.mock_create_order() as api_mock: with self.mock_create_basket() as api_mock:
response = self._post_to_view() response = self._post_to_view()
# Validate the response content # Validate the response content
...@@ -199,7 +210,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -199,7 +210,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
""" """
If the E-Commerce Service is not configured, the view should enroll the user. 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() response = self._post_to_view()
# Validate the response # Validate the response
...@@ -219,7 +230,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -219,7 +230,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode, CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii')) 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() response = self._post_to_view()
# The view should return an error status code # The view should return an error status code
...@@ -274,4 +285,17 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -274,4 +285,17 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assertIsNotNone(get_enrollment(self.user.username, unicode(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')
...@@ -4,10 +4,12 @@ Defines the URL routes for this app. ...@@ -4,10 +4,12 @@ Defines the URL routes for this app.
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from .views import OrdersView, checkout_cancel from .views import BasketsView, checkout_cancel
urlpatterns = patterns( 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"), 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"),
) )
...@@ -3,31 +3,31 @@ import logging ...@@ -3,31 +3,31 @@ import logging
from django.conf import settings from django.conf import settings
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from rest_framework.permissions import IsAuthenticated 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 rest_framework.views import APIView
from commerce.api import EcommerceAPI from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus, Messages from commerce.constants import Messages
from commerce.exceptions import ApiError, InvalidConfigurationError from commerce.exceptions import ApiError, InvalidConfigurationError, InvalidResponseError
from commerce.http import DetailResponse, InternalRequestErrorResponse from commerce.http import DetailResponse, InternalRequestErrorResponse
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware import courses from courseware import courses
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from enrollment.api import add_enrollment from enrollment.api import add_enrollment
from microsite_configuration import microsite from microsite_configuration import microsite
from student.models import CourseEnrollment
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from student.models import CourseEnrollment
from util.json_request import JsonResponse
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class OrdersView(APIView): class BasketsView(APIView):
""" Creates an order with a course seat and enrolls users. """ """ 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! # LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh!
authentication_classes = (SessionAuthenticationAllowInactiveUser,) authentication_classes = (SessionAuthenticationAllowInactiveUser,)
...@@ -63,7 +63,7 @@ class OrdersView(APIView): ...@@ -63,7 +63,7 @@ class OrdersView(APIView):
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 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 user = request.user
valid, course_key, error = self._is_data_valid(request) valid, course_key, error = self._is_data_valid(request)
...@@ -103,28 +103,31 @@ class OrdersView(APIView): ...@@ -103,28 +103,31 @@ class OrdersView(APIView):
# Make the API call # Make the API call
try: try:
order_number, order_status, _body = api.create_order(user, honor_mode.sku) response_data = api.create_basket(
if order_status == OrderStatus.COMPLETE: user,
msg = Messages.ORDER_COMPLETED.format(order_number=order_number) 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) log.debug(msg)
return DetailResponse(msg) return DetailResponse(msg)
else: else:
# TODO Before this functionality is fully rolled-out, this branch should be updated to NOT enroll the # Enroll in the honor mode directly as a failsafe.
# user. Enrollments must be initiated by the E-Commerce API only. # This MUST be removed when this code handles paid modes.
self._enroll(course_key, user) self._enroll(course_key, user)
msg = u'Order %(order_number)s was received with %(status)s status. Expected %(complete_status)s. ' \ msg = u'Unexpected response from basket endpoint.'
u'User %(username)s was enrolled in %(course_id)s by LMS.' log.error(
msg_kwargs = { msg + u' Could not enroll user %(username)s in course %(course_id)s.',
'order_number': order_number, {'username': user.id, 'course_id': course_id},
'status': order_status, )
'complete_status': OrderStatus.COMPLETE, raise InvalidResponseError(msg)
'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)
except ApiError as err: except ApiError as err:
# The API will handle logging of the error. # The API will handle logging of the error.
return InternalRequestErrorResponse(err.message) return InternalRequestErrorResponse(err.message)
......
...@@ -918,24 +918,27 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): ...@@ -918,24 +918,27 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
"""Mocks calls to EcommerceAPI.get_order. """ """Mocks calls to EcommerceAPI.get_order. """
patch = None patch = None
ORDER = copy.deepcopy(EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY) ORDER = {
ORDER['total_excl_tax'] = 40.0 'status': OrderStatus.COMPLETE,
ORDER['currency'] = 'USD' 'number': EcommerceApiTestMixin.ORDER_NUMBER,
ORDER['sources'] = [{'transactions': [ 'total_excl_tax': 40.0,
{'date_created': '2015-04-07 17:59:06.274587+00:00'}, 'currency': 'USD',
{'date_created': '2015-04-08 13:33:06.150000+00:00'}, 'sources': [{'transactions': [
{'date_created': '2015-04-09 10:45:06.200000+00:00'}, {'date_created': '2015-04-07 17:59:06.274587+00:00'},
]}] {'date_created': '2015-04-08 13:33:06.150000+00:00'},
ORDER['billing_address'] = { {'date_created': '2015-04-09 10:45:06.200000+00:00'},
'first_name': 'Philip', ]}],
'last_name': 'Fry', 'billing_address': {
'line1': 'Robot Arms Apts', 'first_name': 'Philip',
'line2': '22 Robot Street', 'last_name': 'Fry',
'line4': 'New New York', 'line1': 'Robot Arms Apts',
'state': 'NY', 'line2': '22 Robot Street',
'postcode': '11201', 'line4': 'New New York',
'country': { 'state': 'NY',
'display_name': 'United States', 'postcode': '11201',
'country': {
'display_name': 'United States',
},
}, },
} }
......
...@@ -58,4 +58,4 @@ class TestProfEdVerification(ModuleStoreTestCase): ...@@ -58,4 +58,4 @@ class TestProfEdVerification(ModuleStoreTestCase):
# On the first page of the flow, verify that there's a button allowing the user # 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. # 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')
...@@ -380,6 +380,14 @@ class PayAndVerifyView(View): ...@@ -380,6 +380,14 @@ class PayAndVerifyView(View):
# Determine the photo verification status # Determine the photo verification status
verification_good_until = self._verification_valid_until(request.user) 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 # Render the top-level page
context = { context = {
'contribution_amount': contribution_amount, 'contribution_amount': contribution_amount,
...@@ -393,7 +401,7 @@ class PayAndVerifyView(View): ...@@ -393,7 +401,7 @@ class PayAndVerifyView(View):
'is_active': json.dumps(request.user.is_active), 'is_active': json.dumps(request.user.is_active),
'message_key': message, 'message_key': message,
'platform_name': settings.PLATFORM_NAME, 'platform_name': settings.PLATFORM_NAME,
'purchase_endpoint': get_purchase_endpoint(), 'processors': processors,
'requirements': requirements, 'requirements': requirements,
'user_full_name': full_name, 'user_full_name': full_name,
'verification_deadline': ( 'verification_deadline': (
...@@ -644,26 +652,59 @@ class PayAndVerifyView(View): ...@@ -644,26 +652,59 @@ class PayAndVerifyView(View):
return (has_paid, bool(is_active)) return (has_paid, bool(is_active))
def create_order_with_ecommerce_service(user, course_key, course_mode): # pylint: disable=invalid-name def checkout_with_ecommerce_service(user, course_key, course_mode, processor): # pylint: disable=invalid-name
""" Create a new order using the E-Commerce API. """ """ Create a new basket and trigger immediate checkout, using the E-Commerce API. """
try: try:
api = EcommerceAPI() api = EcommerceAPI()
# Make an API call to create the order and retrieve the results # 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. # 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: except ApiError:
params = {'username': user.username, 'mode': course_mode.slug, 'course_id': unicode(course_key)} 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) log.error('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
raise 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 @require_POST
@login_required @login_required
def create_order(request): 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. # Only submit photos if photo data is provided by the client.
# TODO (ECOM-188): Once the A/B test of decoupling verified / payment # TODO (ECOM-188): Once the A/B test of decoupling verified / payment
...@@ -724,35 +765,23 @@ def create_order(request): ...@@ -724,35 +765,23 @@ def create_order(request):
return HttpResponseBadRequest(_("No selected price or selected price is below minimum.")) return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
if current_mode.sku: 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(
# I know, we should check this is valid. All kinds of stuff missing here request.user,
cart = Order.get_cart_for_user(request.user) course_id,
cart.clear() current_mode,
enrollment_mode = current_mode.slug request.POST.get('processor')
CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode) )
else:
# Change the order's status so that we don't accidentally modify it later. payment_data = checkout_with_shoppingcart(request, request.user, course_id, current_mode, amount)
# We need to do this to ensure that the parameters we send to the payment system
# match what we store in the database. if 'processor' not in request.POST:
# (Ordinarily we would do this client-side when the user submits the form, but since # (XCOM-214) To be removed after release.
# the JavaScript on this page does that immediately, we make the change here instead. # the absence of this key in the POST payload indicates that the request was initiated from
# This avoids a second AJAX call and some additional complication of the JavaScript.) # a stale js client, which expects a response containing only the 'payment_form_data' part of
# If a user later re-enters the verification / payment flow, she will create a new order. # the payment data result.
cart.start_purchase() payment_data = payment_data['payment_form_data']
return HttpResponse(json.dumps(payment_data), content_type="application/json")
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")
@require_POST @require_POST
......
...@@ -4,7 +4,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], ...@@ -4,7 +4,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
describe("Photo Verification", function () { describe("Photo Verification", function () {
beforeEach(function () { beforeEach(function () {
setFixtures('<div id="order-error" style="display: none;"></div><input type="radio" name="contribution" value="35" id="contribution-35" checked="checked"><input type="radio" id="contribution-other" name="contribution" value=""><input type="text" size="9" name="contribution-other-amt" id="contribution-other-amt" value="30"><img id="face_image" src="src="data:image/png;base64,dummy"><img id="photo_id_image" src="src="data:image/png;base64,dummy"><button id="pay_button">pay button</button>'); setFixtures('<div id="order-error" style="display: none;"></div><input type="radio" name="contribution" value="35" id="contribution-35" checked="checked"><input type="radio" id="contribution-other" name="contribution" value=""><input type="text" size="9" name="contribution-other-amt" id="contribution-other-amt" value="30"><img id="face_image" src="src="data:image/png;base64,dummy"><img id="photo_id_image" src="src="data:image/png;base64,dummy"><button class="payment-button">pay button</button>');
}); });
it('retake photo', function () { it('retake photo', function () {
...@@ -27,7 +27,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], ...@@ -27,7 +27,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
}); });
submitToPaymentProcessing(); submitToPaymentProcessing();
expect(window.submitForm).toHaveBeenCalled(); expect(window.submitForm).toHaveBeenCalled();
expect($("#pay_button")).toHaveClass("is-disabled"); expect($(".payment-button")).toHaveClass("is-disabled");
}); });
it('Error during process', function () { it('Error during process', function () {
...@@ -44,7 +44,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], ...@@ -44,7 +44,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
expect(window.showSubmissionError).toHaveBeenCalled(); expect(window.showSubmissionError).toHaveBeenCalled();
// make sure the button isn't disabled // 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 // but also make sure that it was disabled during the ajax call
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled"); expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
......
...@@ -5,7 +5,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'], ...@@ -5,7 +5,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'],
describe( 'edx.student.account.EnrollmentInterface', function() { describe( 'edx.student.account.EnrollmentInterface', function() {
var COURSE_KEY = 'edX/DemoX/Fall', var COURSE_KEY = 'edX/DemoX/Fall',
ENROLL_URL = '/commerce/orders/', ENROLL_URL = '/commerce/baskets/',
FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/', FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/',
EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/'; EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/';
......
...@@ -11,8 +11,6 @@ define([ ...@@ -11,8 +11,6 @@ define([
describe( 'edx.verify_student.MakePaymentStepView', function() { describe( 'edx.verify_student.MakePaymentStepView', function() {
var PAYMENT_URL = "/pay";
var PAYMENT_PARAMS = { var PAYMENT_PARAMS = {
orderId: "test-order", orderId: "test-order",
signature: "abcd1234" signature: "abcd1234"
...@@ -21,7 +19,7 @@ define([ ...@@ -21,7 +19,7 @@ define([
var STEP_DATA = { var STEP_DATA = {
minPrice: "12", minPrice: "12",
currency: "usd", currency: "usd",
purchaseEndpoint: PAYMENT_URL, processors: ["test-payment-processor"],
courseKey: "edx/test/test", courseKey: "edx/test/test",
courseModeSlug: 'verified' courseModeSlug: 'verified'
}; };
...@@ -50,15 +48,16 @@ define([ ...@@ -50,15 +48,16 @@ define([
}; };
var expectPaymentButtonEnabled = function( isEnabled ) { var expectPaymentButtonEnabled = function( isEnabled ) {
var appearsDisabled = $( '#pay_button' ).hasClass( 'is-disabled' ), var el = $( '.payment-button'),
isDisabled = $( '#pay_button' ).prop( 'disabled' ); appearsDisabled = el.hasClass( 'is-disabled' ),
isDisabled = el.prop( 'disabled' );
expect( !appearsDisabled ).toEqual( isEnabled ); expect( !appearsDisabled ).toEqual( isEnabled );
expect( !isDisabled ).toEqual( isEnabled ); expect( !isDisabled ).toEqual( isEnabled );
}; };
var expectPaymentDisabledBecauseInactive = function() { var expectPaymentDisabledBecauseInactive = function() {
var payButton = $( '#pay_button' ); var payButton = $( '.payment_button' );
// Payment button should be hidden // Payment button should be hidden
expect( payButton.length ).toEqual(0); expect( payButton.length ).toEqual(0);
...@@ -67,21 +66,22 @@ define([ ...@@ -67,21 +66,22 @@ define([
var goToPayment = function( requests, kwargs ) { var goToPayment = function( requests, kwargs ) {
var params = { var params = {
contribution: kwargs.amount || "", contribution: kwargs.amount || "",
course_id: kwargs.courseId || "" course_id: kwargs.courseId || "",
processor: kwargs.processor || ""
}; };
// Click the "go to payment" button // Click the "go to payment" button
$( '#pay_button' ).click(); $( '.payment-button' ).click();
// Verify that the request was made to the server // Verify that the request was made to the server
AjaxHelpers.expectRequest( AjaxHelpers.expectPostRequest(
requests, "POST", "/verify_student/create_order/", requests, "/verify_student/create_order/", $.param( params )
$.param( params )
); );
// Simulate the server response // Simulate the server response
if ( kwargs.succeeds ) { 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 { } else {
AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG ); AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG );
} }
...@@ -95,7 +95,7 @@ define([ ...@@ -95,7 +95,7 @@ define([
expect(form.serialize()).toEqual($.param(params)); expect(form.serialize()).toEqual($.param(params));
expect(form.attr('method')).toEqual("POST"); expect(form.attr('method')).toEqual("POST");
expect(form.attr('action')).toEqual(PAYMENT_URL); expect(form.attr('action')).toEqual('http://payment-page-url/');
}; };
beforeEach(function() { beforeEach(function() {
...@@ -114,9 +114,10 @@ define([ ...@@ -114,9 +114,10 @@ define([
goToPayment( requests, { goToPayment( requests, {
amount: STEP_DATA.minPrice, amount: STEP_DATA.minPrice,
courseId: STEP_DATA.courseKey, courseId: STEP_DATA.courseKey,
processor: STEP_DATA.processors[0],
succeeds: true succeeds: true
}); });
expectPaymentSubmitted( view, PAYMENT_PARAMS ); expectPaymentSubmitted( view, {foo: 'bar'} );
}); });
it( 'by default minimum price is selected if no suggested prices are given', function() { it( 'by default minimum price is selected if no suggested prices are given', function() {
...@@ -129,9 +130,10 @@ define([ ...@@ -129,9 +130,10 @@ define([
goToPayment( requests, { goToPayment( requests, {
amount: STEP_DATA.minPrice, amount: STEP_DATA.minPrice,
courseId: STEP_DATA.courseKey, courseId: STEP_DATA.courseKey,
processor: STEP_DATA.processors[0],
succeeds: true succeeds: true
}); });
expectPaymentSubmitted( view, PAYMENT_PARAMS ); expectPaymentSubmitted( view, {foo: 'bar'} );
}); });
it( 'min price is always selected even if contribution amount is provided', function() { it( 'min price is always selected even if contribution amount is provided', function() {
...@@ -156,6 +158,7 @@ define([ ...@@ -156,6 +158,7 @@ define([
goToPayment( requests, { goToPayment( requests, {
amount: STEP_DATA.minPrice, amount: STEP_DATA.minPrice,
courseId: STEP_DATA.courseKey, courseId: STEP_DATA.courseKey,
processor: STEP_DATA.processors[0],
succeeds: false succeeds: false
}); });
......
...@@ -9,7 +9,7 @@ var edx = edx || {}; ...@@ -9,7 +9,7 @@ var edx = edx || {};
edx.student.account.EnrollmentInterface = { edx.student.account.EnrollmentInterface = {
urls: { urls: {
orders: '/commerce/orders/', baskets: '/commerce/baskets/',
}, },
headers: { headers: {
...@@ -26,7 +26,7 @@ var edx = edx || {}; ...@@ -26,7 +26,7 @@ var edx = edx || {};
data = JSON.stringify(data_obj); data = JSON.stringify(data_obj);
$.ajax({ $.ajax({
url: this.urls.orders, url: this.urls.baskets,
type: 'POST', type: 'POST',
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
data: data, data: data,
......
...@@ -59,7 +59,7 @@ var edx = edx || {}; ...@@ -59,7 +59,7 @@ var edx = edx || {};
function( price ) { return Boolean( price ); } function( price ) { return Boolean( price ); }
), ),
currency: el.data('course-mode-currency'), currency: el.data('course-mode-currency'),
purchaseEndpoint: el.data('purchase-endpoint'), processors: el.data('processors'),
verificationDeadline: el.data('verification-deadline'), verificationDeadline: el.data('verification-deadline'),
courseModeSlug: el.data('course-mode-slug'), courseModeSlug: el.data('course-mode-slug'),
alreadyVerified: el.data('already-verified'), alreadyVerified: el.data('already-verified'),
......
...@@ -69,7 +69,7 @@ function refereshPageMessage() { ...@@ -69,7 +69,7 @@ function refereshPageMessage() {
} }
var submitToPaymentProcessing = function() { 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_input = $("input[name='contribution']:checked")
var contribution = 0; var contribution = 0;
if(contribution_input.attr('id') == 'contribution-other') { if(contribution_input.attr('id') == 'contribution-other') {
...@@ -96,7 +96,7 @@ var submitToPaymentProcessing = function() { ...@@ -96,7 +96,7 @@ var submitToPaymentProcessing = function() {
} }
}, },
error:function(xhr,status,error) { error:function(xhr,status,error) {
$("#pay_button").removeClass('is-disabled').attr('aria-disabled', false); $(".payment-button").removeClass('is-disabled').attr('aria-disabled', false);
showSubmissionError() showSubmissionError()
} }
}); });
...@@ -290,7 +290,7 @@ function waitForFlashLoad(func, flash_object) { ...@@ -290,7 +290,7 @@ function waitForFlashLoad(func, flash_object) {
$(document).ready(function() { $(document).ready(function() {
$(".carousel-nav").addClass('sr'); $(".carousel-nav").addClass('sr');
$("#pay_button").click(function(){ $(".payment-button").click(function(){
analytics.pageview("Payment Form"); analytics.pageview("Payment Form");
submitToPaymentProcessing(); submitToPaymentProcessing();
}); });
...@@ -306,7 +306,7 @@ $(document).ready(function() { ...@@ -306,7 +306,7 @@ $(document).ready(function() {
// prevent browsers from keeping this button checked // prevent browsers from keeping this button checked
$("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").prop("checked", false)
$("#confirm_pics_good").change(function() { $("#confirm_pics_good").change(function() {
$("#pay_button").toggleClass('disabled'); $(".payment-button").toggleClass('disabled');
$("#reverify_button").toggleClass('disabled'); $("#reverify_button").toggleClass('disabled');
$("#midcourse_reverify_button").toggleClass('disabled'); $("#midcourse_reverify_button").toggleClass('disabled');
}); });
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
*/ */
var edx = edx || {}; var edx = edx || {};
(function( $, _, gettext ) { (function( $, _, gettext, interpolate_text ) {
'use strict'; 'use strict';
edx.verify_student = edx.verify_student || {}; edx.verify_student = edx.verify_student || {};
...@@ -28,12 +28,45 @@ var edx = edx || {}; ...@@ -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(
'<a class="next action-primary payment-button" id="<%- name %>" tab-index="0"><%- text %></a> '
)({name: processorName, text: self._getPaymentButtonText(processorName)});
},
postRender: function() { postRender: function() {
var templateContext = this.templateContext(), var templateContext = this.templateContext(),
hasVisibleReqs = _.some( hasVisibleReqs = _.some(
templateContext.requirements, templateContext.requirements,
function( isVisible ) { return isVisible; } function( isVisible ) { return isVisible; }
); ),
self = this;
// Track a virtual pageview, for easy funnel reconstruction. // Track a virtual pageview, for easy funnel reconstruction.
window.analytics.page( 'payment', this.templateName ); window.analytics.page( 'payment', this.templateName );
...@@ -59,25 +92,41 @@ var edx = edx || {}; ...@@ -59,25 +92,41 @@ var edx = edx || {};
this.setPaymentEnabled( true ); 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 // Handle payment submission
$( '#pay_button' ).on( 'click', _.bind( this.createOrder, this ) ); $( '.payment-button' ).on( 'click', _.bind( this.createOrder, this ) );
}, },
setPaymentEnabled: function( isEnabled ) { setPaymentEnabled: function( isEnabled ) {
if ( _.isUndefined( isEnabled ) ) { if ( _.isUndefined( isEnabled ) ) {
isEnabled = true; isEnabled = true;
} }
$( '#pay_button' ) $( '.payment-button' )
.toggleClass( 'is-disabled', !isEnabled ) .toggleClass( 'is-disabled', !isEnabled )
.prop( 'disabled', !isEnabled ) .prop( 'disabled', !isEnabled )
.attr('aria-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(), var paymentAmount = this.getPaymentAmount(),
postData = { postData = {
'processor': event.target.id,
'contribution': paymentAmount, 'contribution': paymentAmount,
'course_id': this.stepData.courseKey, 'course_id': this.stepData.courseKey
}; };
// Disable the payment button to prevent multiple submissions // Disable the payment button to prevent multiple submissions
...@@ -98,21 +147,21 @@ var edx = edx || {}; ...@@ -98,21 +147,21 @@ var edx = edx || {};
}, },
handleCreateOrderResponse: function( paymentParams ) { handleCreateOrderResponse: function( paymentData ) {
// At this point, the order has been created on the server, // At this point, the basket has been created on the server,
// and we've received signed payment parameters. // and we've received signed payment parameters.
// We need to dynamically construct a form using // We need to dynamically construct a form using
// these parameters, then submit it to the payment processor. // these parameters, then submit it to the payment processor.
// This will send the user to a hosted order page, // This will send the user to an externally-hosted page
// where she can enter credit card information. // where she can proceed with payment.
var form = $( '#payment-processor-form' ); var form = $( '#payment-processor-form' );
$( 'input', form ).remove(); $( 'input', form ).remove();
form.attr( 'action', this.stepData.purchaseEndpoint ); form.attr( 'action', paymentData.payment_page_url );
form.attr( 'method', 'POST' ); form.attr( 'method', 'POST' );
_.each( paymentParams, function( value, key ) { _.each( paymentData.payment_form_data, function( value, key ) {
$('<input>').attr({ $('<input>').attr({
type: 'hidden', type: 'hidden',
name: key, name: key,
...@@ -200,4 +249,4 @@ var edx = edx || {}; ...@@ -200,4 +249,4 @@ var edx = edx || {};
}); });
})( jQuery, _, gettext ); })( jQuery, _, gettext, interpolate_text );
...@@ -50,3 +50,22 @@ ...@@ -50,3 +50,22 @@
padding: ($baseline*1.5) $baseline; padding: ($baseline*1.5) $baseline;
text-align: center; 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) );
}
}
...@@ -98,11 +98,16 @@ ...@@ -98,11 +98,16 @@
<% } %> <% } %>
<% if ( isActive ) { %> <% if ( isActive ) { %>
<div class="nav-wizard is-ready center"> <div class="payment-buttons nav-wizard is-ready center">
<input type="hidden" name="contribution" value="<%- minPrice %>" /> <input type="hidden" name="contribution" value="<%- minPrice %>" />
<a class="next action-primary" id="pay_button" tab-index="0"> <div class="purchase">
<%- gettext( "Continue to payment" ) %> ($<%- minPrice %>) <p class="product-info"><span class="product-name"></span> <%- gettext( "price" ) %>: <span class="price">$<%- minPrice %></span></p>
</a> </div>
<div class="pay-options">
<%
// payment buttons will go here
%>
</div>
</div> </div>
<% } %> <% } %>
......
...@@ -67,7 +67,7 @@ from verify_student.views import PayAndVerifyView ...@@ -67,7 +67,7 @@ from verify_student.views import PayAndVerifyView
data-course-mode-suggested-prices='${course_mode.suggested_prices}' data-course-mode-suggested-prices='${course_mode.suggested_prices}'
data-course-mode-currency='${course_mode.currency}' data-course-mode-currency='${course_mode.currency}'
data-contribution-amount='${contribution_amount}' data-contribution-amount='${contribution_amount}'
data-purchase-endpoint='${purchase_endpoint}' data-processors='${json.dumps(processors)}'
data-verification-deadline='${verification_deadline}' data-verification-deadline='${verification_deadline}'
data-display-steps='${json.dumps(display_steps)}' data-display-steps='${json.dumps(display_steps)}'
data-current-step='${current_step}' data-current-step='${current_step}'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment