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, _) {
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,
......
......@@ -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()
......
......@@ -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 list of strings.
"""
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():
"""Internal service call to create an order. """
"""Internal service call to create a basket. """
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 = '{}/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 = {
......
......@@ -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):
......
......@@ -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()
......
......@@ -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)
......@@ -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')
......@@ -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"),
)
......@@ -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)
......
......@@ -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',
},
},
}
......
......@@ -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')
......@@ -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)
# 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 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)
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
......
......@@ -4,7 +4,7 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'],
describe("Photo Verification", 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 () {
......@@ -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");
......
......@@ -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/';
......
......@@ -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
});
......
......@@ -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,
......
......@@ -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'),
......
......@@ -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');
});
......
......@@ -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(
'<a class="next action-primary payment-button" id="<%- name %>" tab-index="0"><%- text %></a> '
)({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 ) {
$('<input>').attr({
type: 'hidden',
name: key,
......@@ -200,4 +249,4 @@ var edx = edx || {};
});
})( jQuery, _, gettext );
})( jQuery, _, gettext, interpolate_text );
......@@ -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) );
}
}
......@@ -98,11 +98,16 @@
<% } %>
<% 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 %>" />
<a class="next action-primary" id="pay_button" tab-index="0">
<%- gettext( "Continue to payment" ) %> ($<%- minPrice %>)
</a>
<div class="purchase">
<p class="product-info"><span class="product-name"></span> <%- gettext( "price" ) %>: <span class="price">$<%- minPrice %></span></p>
</div>
<div class="pay-options">
<%
// payment buttons will go here
%>
</div>
</div>
<% } %>
......
......@@ -67,7 +67,7 @@ from verify_student.views import PayAndVerifyView
data-course-mode-suggested-prices='${course_mode.suggested_prices}'
data-course-mode-currency='${course_mode.currency}'
data-contribution-amount='${contribution_amount}'
data-purchase-endpoint='${purchase_endpoint}'
data-processors='${json.dumps(processors)}'
data-verification-deadline='${verification_deadline}'
data-display-steps='${json.dumps(display_steps)}'
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