Commit 1a1fe56b by Clinton Blackburn Committed by Clinton Blackburn

Added Oscar-Compatible Receipt Page

parent 270ac747
......@@ -271,6 +271,10 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
def test_user_does_not_match_param(self):
"""
The view should return status 404 if the enrollment username does not match the username of the user
making the request, unless the request is made by a superuser or with a server API key.
"""
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=CourseMode.HONOR,
......@@ -279,13 +283,19 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
url = reverse('courseenrollment',
kwargs={'username': self.other_user.username, "course_id": unicode(self.course.id)})
resp = self.client.get(url)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
# Verify that the server still has access to this endpoint.
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
self.client.logout()
resp = self.client.get(url, **{'HTTP_X_EDX_API_KEY': self.API_KEY})
self.assertEqual(resp.status_code, status.HTTP_200_OK)
response = self.client.get(url, **{'HTTP_X_EDX_API_KEY': self.API_KEY})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify superusers have access to this endpoint
superuser = UserFactory.create(password=self.PASSWORD, is_superuser=True)
self.client.login(username=superuser.username, password=self.PASSWORD)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_get_course_details(self):
CourseModeFactory.create(
......
......@@ -135,7 +135,9 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn):
"""
username = username or request.user.username
if request.user.username != username and not self.has_api_key_permissions(request):
# TODO Implement proper permissions
if request.user.username != username and not self.has_api_key_permissions(request) \
and not request.user.is_superuser:
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
......
""" Commerce app. """
from django.conf import settings
from ecommerce_api_client.client import EcommerceApiClient
def ecommerce_api_client(user):
""" Returns an E-Commerce API client setup with authentication for the specified user. """
return EcommerceApiClient(settings.ECOMMERCE_API_URL, settings.ECOMMERCE_API_SIGNING_KEY, user.username,
user.email)
""" E-Commerce API client """
import json
import logging
from django.conf import settings
import jwt
import requests
from requests import Timeout
from rest_framework.status import HTTP_200_OK
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
log = logging.getLogger(__name__)
class EcommerceAPI(object):
""" E-Commerce API client. """
def __init__(self, url=None, key=None, timeout=None):
self.url = url or settings.ECOMMERCE_API_URL
self.key = key or settings.ECOMMERCE_API_SIGNING_KEY
self.timeout = timeout or getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
if not (self.url and self.key):
raise InvalidConfigurationError('Values for both url and key must be set.')
# Remove slashes, so that we can properly format URLs regardless of
# whether the input includes a trailing slash.
self.url = self.url.strip('/')
def _get_jwt(self, user):
"""
Returns a JWT object with the specified user's info.
Raises AttributeError if settings.ECOMMERCE_API_SIGNING_KEY is not set.
"""
data = {
'username': user.username,
'email': user.email
}
return jwt.encode(data, self.key)
def get_order(self, user, order_number):
"""
Retrieve a paid order.
Arguments
user -- User associated with the requested order.
order_number -- The unique identifier for the order.
Returns a tuple with the order number, order status, API response data.
"""
def get():
"""Internal service call to retrieve an order. """
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user))
}
url = '{base_url}/orders/{order_number}/'.format(base_url=self.url, order_number=order_number)
return requests.get(url, headers=headers, timeout=self.timeout)
data = self._call_ecommerce_service(get)
return data['number'], data['status'], data
def get_processors(self, user):
"""
Retrieve the list of available payment processors.
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)
return self._call_ecommerce_service(get)
def create_basket(self, user, sku, payment_processor=None):
"""Create a new basket and immediately trigger checkout.
Note that while the API supports deferring checkout to a separate step,
as well as adding multiple products to the basket, this client does not
currently need that capability, so that case is not supported.
Args:
user: the django.auth.User for which the basket should be created.
sku: a string containing the SKU of the course seat being ordered.
payment_processor: (optional) the name of the payment processor to
use for checkout.
Returns:
A dictionary containing {id, order, payment_data}.
Raises:
TimeoutError: the request to the API server timed out.
InvalidResponseError: the API server response was not understood.
"""
def create():
"""Internal service call to create a basket. """
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user))
}
url = '{}/baskets/'.format(self.url)
data = {'products': [{'sku': sku}], 'checkout': True, 'payment_processor_name': payment_processor}
return requests.post(url, data=json.dumps(data), headers=headers, timeout=self.timeout)
return self._call_ecommerce_service(create)
@staticmethod
def _call_ecommerce_service(call):
"""
Makes a call to the E-Commerce Service. There are a number of common errors that could occur across any
request to the E-Commerce Service that this helper method can wrap each call with. This method helps ensure
calls to the E-Commerce Service will conform to the same output.
Arguments
call -- A callable function that makes a request to the E-Commerce Service.
Returns a dict of JSON-decoded API response data.
"""
try:
response = call()
data = response.json()
except Timeout:
msg = 'E-Commerce API request timed out.'
log.error(msg)
raise TimeoutError(msg)
except ValueError:
msg = 'E-Commerce API response is not valid JSON.'
log.exception(msg)
raise InvalidResponseError(msg)
status_code = response.status_code
if status_code == HTTP_200_OK:
return data
else:
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
msg_kwargs = {
'status': status_code,
'msg': data.get('user_message'),
}
log.error(msg, msg_kwargs)
raise InvalidResponseError(msg % msg_kwargs)
""" E-Commerce-related exceptions. """
class ApiError(Exception):
""" Base class for E-Commerce API errors. """
pass
class InvalidConfigurationError(ApiError):
""" Exception raised when the API is not properly configured (e.g. settings are not set). """
pass
class InvalidResponseError(ApiError):
class InvalidResponseError(Exception):
""" Exception raised when an API response is invalid. """
pass
class TimeoutError(ApiError):
""" Exception raised when an API requests times out. """
pass
......@@ -5,7 +5,7 @@ import httpretty
import jwt
import mock
from commerce.api import EcommerceAPI
from ecommerce_api_client.client import EcommerceApiClient
class EcommerceApiTestMixin(object):
......@@ -24,7 +24,7 @@ class EcommerceApiTestMixin(object):
ORDER_DATA = {'number': ORDER_NUMBER}
ECOMMERCE_API_SUCCESSFUL_BODY = {
'id': BASKET_ID,
'order': {'number': ORDER_NUMBER}, # never both None.
'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
......@@ -61,21 +61,41 @@ class EcommerceApiTestMixin(object):
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,
adding_headers={'Content-Type': 'application/json'})
class mock_create_basket(object): # pylint: disable=invalid-name
""" Mocks calls to EcommerceAPI.create_basket. """
class mock_create_basket(object): # pylint: disable=invalid-name
""" Mocks calls to E-Commerce API client basket creation method. """
patch = None
def __init__(self, **kwargs):
default_kwargs = {'return_value': EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY}
default_kwargs.update(kwargs)
self.patch = mock.patch.object(EcommerceAPI, 'create_basket', mock.Mock(**default_kwargs))
_mock = mock.Mock()
_mock.post = mock.Mock(**default_kwargs)
EcommerceApiClient.baskets = _mock
self.patch = _mock
def __enter__(self):
self.patch.start()
return self.patch.new
return self.patch
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
self.patch.stop()
pass
class mock_basket_order(object): # pylint: disable=invalid-name
""" Mocks calls to E-Commerce API client basket order method. """
patch = None
def __init__(self, **kwargs):
_mock = mock.Mock()
_mock.order.get = mock.Mock(**kwargs)
EcommerceApiClient.baskets = lambda client, basket_id: _mock
self.patch = _mock
def __enter__(self):
return self.patch
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
pass
""" Tests the E-Commerce API module. """
import json
from ddt import ddt, data
from django.core.urlresolvers import reverse
from django.test.testcases import TestCase
from django.test.utils import override_settings
import httpretty
from requests import Timeout
from commerce.api import EcommerceAPI
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
from commerce.tests import EcommerceApiTestMixin
from student.tests.factories import UserFactory
@ddt
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
""" Tests for the E-Commerce API client. """
SKU = '1234'
def setUp(self):
super(EcommerceAPITests, self).setUp()
self.url = reverse('commerce:baskets')
self.user = UserFactory()
self.api = EcommerceAPI()
def test_constructor_url_strip(self):
""" Verifies that the URL is stored with trailing slashes removed. """
url = 'http://example.com'
api = EcommerceAPI(url, 'edx')
self.assertEqual(api.url, url)
api = EcommerceAPI(url + '/', 'edx')
self.assertEqual(api.url, url)
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_no_settings(self):
"""
If the settings ECOMMERCE_API_URL and ECOMMERCE_API_SIGNING_KEY are invalid, the constructor should
raise a ValueError.
"""
self.assertRaises(InvalidConfigurationError, EcommerceAPI)
@httpretty.activate
@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(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.assertValidBasketRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU, self.PROCESSOR)
# Validate the data returned by the method
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_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_basket, self.user, self.SKU, self.PROCESSOR)
@httpretty.activate
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_basket, self.user, self.SKU, self.PROCESSOR)
@httpretty.activate
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):
""" Simulates API timeout """
raise Timeout
self._mock_ecommerce_api(body=request_callback)
self.assertRaises(TimeoutError, self.api.create_basket, self.user, self.SKU, self.PROCESSOR)
......@@ -5,12 +5,13 @@ from uuid import uuid4
from ddt import ddt, data
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ecommerce_api_client import exceptions
from commerce.constants import Messages
from commerce.exceptions import TimeoutError, ApiError
from commerce.tests import EcommerceApiTestMixin
from course_modes.models import CourseMode
from enrollment.api import get_enrollment
......@@ -19,18 +20,26 @@ from student.tests.factories import UserFactory, CourseModeFactory
from student.tests.tests import EnrollmentEventTestMixin
class UserMixin(object):
""" Mixin for tests involving users. """
def setUp(self):
super(UserMixin, self).setUp()
self.user = UserFactory()
def _login(self):
""" Log into LMS. """
self.client.login(username=self.user.username, password='test')
@ddt
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, UserMixin, ModuleStoreTestCase):
"""
Tests for the commerce orders view.
"""
def _login(self):
""" Log into LMS. """
self.client.login(username=self.user.username, password='test')
def _post_to_view(self, course_id=None):
"""
POST to the view being tested.
......@@ -67,7 +76,6 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
def setUp(self):
super(BasketsViewTests, self).setUp()
self.url = reverse('commerce:baskets')
self.user = UserFactory()
self._login()
self.course = CourseFactory.create()
......@@ -118,7 +126,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
"""
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_basket(side_effect=TimeoutError):
with self.mock_create_basket(side_effect=exceptions.Timeout):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
......@@ -128,7 +136,7 @@ class BasketsViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSt
"""
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
"""
with self.mock_create_basket(side_effect=ApiError):
with self.mock_create_basket(side_effect=exceptions.SlumberBaseException):
response = self._post_to_view()
self.assertValidEcommerceInternalRequestErrorResponse(response)
......@@ -296,6 +304,52 @@ class OrdersViewTests(BasketsViewTests):
(XCOM-214) remove after release.
"""
def setUp(self):
super(OrdersViewTests, self).setUp()
self.url = reverse('commerce:orders')
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class BasketOrderViewTests(UserMixin, EcommerceApiTestMixin, TestCase):
""" Tests for the basket order view. """
view_name = 'commerce:basket_order'
MOCK_ORDER = {'number': 1}
path = reverse(view_name, kwargs={'basket_id': 1})
def setUp(self):
super(BasketOrderViewTests, self).setUp()
self._login()
def test_order_found(self):
""" If the order is located, the view should pass the data from the API. """
with self.mock_basket_order(return_value=self.MOCK_ORDER):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, self.MOCK_ORDER)
def test_order_not_found(self):
""" If the order is not found, the view should return a 404. """
with self.mock_basket_order(side_effect=exceptions.HttpNotFoundError):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 404)
def test_login_required(self):
""" The view should return 403 if the user is not logged in. """
self.client.logout()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 403)
class ReceiptViewTests(TestCase):
""" Tests for the receipt view. """
def test_login_required(self):
""" The view should redirect to the login page if the user is not logged in. """
self.client.logout()
response = self.client.get(reverse('commerce:checkout_receipt'))
self.assertEqual(response.status_code, 302)
......@@ -4,12 +4,15 @@ Defines the URL routes for this app.
from django.conf.urls import patterns, url
from .views import BasketsView, checkout_cancel
from commerce import views
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
urlpatterns = patterns(
'',
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"),
url(r'^orders/$', views.BasketsView.as_view(), name="orders"),
url(r'^baskets/$', views.BasketsView.as_view(), name="baskets"),
url(r'^baskets/{}/order/$'.format(BASKET_ID_PATTERN), views.BasketOrderView.as_view(), name="basket_order"),
url(r'^checkout/cancel/$', views.checkout_cancel, name="checkout_cancel"),
url(r'^checkout/receipt/$', views.checkout_receipt, name="checkout_receipt"),
)
......@@ -2,25 +2,30 @@
import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page
from django.views.decorators.csrf import csrf_exempt
from ecommerce_api_client import exceptions
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
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 import ecommerce_api_client
from commerce.constants import Messages
from commerce.exceptions import ApiError, InvalidConfigurationError, InvalidResponseError
from commerce.exceptions import 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 openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from student.models import CourseEnrollment
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from util.json_request import JsonResponse
from verify_student.models import SoftwareSecurePhotoVerification
log = logging.getLogger(__name__)
......@@ -92,10 +97,11 @@ class BasketsView(APIView):
self._enroll(course_key, user)
return DetailResponse(msg)
# Setup the API and report any errors if settings are not valid.
# Setup the API
try:
api = EcommerceAPI()
except InvalidConfigurationError:
api = ecommerce_api_client(user)
except ValueError:
self._enroll(course_key, user)
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
log.debug(msg)
......@@ -103,34 +109,31 @@ class BasketsView(APIView):
# Make the API call
try:
response_data = api.create_basket(
user,
honor_mode.sku,
payment_processor="cybersource",
)
response_data = api.baskets.post({
'products': [{'sku': honor_mode.sku}],
'checkout': True,
'payment_processor_name': '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.
if payment_data:
# Pass data to the client to begin the payment flow.
return JsonResponse(payment_data)
elif response_data['order']:
# the order was completed immediately because there was no charge.
# The order was completed immediately because there isno charge.
msg = Messages.ORDER_COMPLETED.format(order_number=response_data['order']['number'])
log.debug(msg)
return DetailResponse(msg)
else:
# 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'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)
except (exceptions.SlumberBaseException, exceptions.Timeout) as ex:
log.exception(ex.message)
return InternalRequestErrorResponse(ex.message)
@cache_page(1800)
......@@ -138,3 +141,29 @@ def checkout_cancel(_request):
""" Checkout/payment cancellation view. """
context = {'payment_support_email': microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)}
return render_to_response("commerce/checkout_cancel.html", context)
@csrf_exempt
@login_required
def checkout_receipt(request):
""" Receipt view. """
context = {
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
'verified': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists()
}
return render_to_response('commerce/checkout_receipt.html', context)
class BasketOrderView(APIView):
""" Retrieve the order associated with a basket. """
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request, *_args, **kwargs):
""" HTTP handler. """
try:
order = ecommerce_api_client(request.user).baskets(kwargs['basket_id']).order.get()
return JsonResponse(order)
except exceptions.HttpNotFoundError:
return JsonResponse(status=404)
......@@ -2,8 +2,6 @@
Tests for Shopping Cart views
"""
from collections import OrderedDict
import copy
import mock
import pytz
from urlparse import urlparse
from decimal import Decimal
......@@ -29,9 +27,6 @@ import ddt
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus
from commerce.tests import EcommerceApiTestMixin
from student.roles import CourseSalesAdminRole
from util.date_utils import get_default_time_display
from util.testing import UrlResetMixin
......@@ -93,7 +88,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
min_price=self.cost)
self.course_mode.save()
#Saving another testing course mode
# Saving another testing course mode
self.testing_cost = 20
self.testing_course = CourseFactory.create(org='edX', number='888', display_name='Testing Super Course')
self.testing_course_mode = CourseMode(course_id=self.testing_course.id,
......@@ -868,112 +863,10 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
'unit_cost': 40,
'quantity': 1,
'line_cost': 40,
'line_desc': 'Honor Code Certificate for course Test Course'
'line_desc': 'Honor Code Certificate for course Test Course',
'course_key': unicode(self.verified_course_key)
})
@ddt.data(0, 1, 2)
@override_settings(
ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY
)
def test_show_ecom_receipt_json(self, num_items):
# set up the get request to return an order with X number of line items.
# Log in the student. Use a false order ID for the E-Commerce Application.
self.login_user()
url = reverse('shoppingcart.views.show_receipt', args=['EDX-100042'])
with self.mock_get_order(num_items=num_items):
resp = self.client.get(url, HTTP_ACCEPT="application/json")
# Should have gotten a successful response
self.assertEqual(resp.status_code, 200)
# Parse the response as JSON and check the contents
json_resp = json.loads(resp.content)
self.assertEqual(json_resp.get('currency'), self.mock_get_order.ORDER['currency'])
self.assertEqual(json_resp.get('purchase_datetime'), 'Apr 07, 2015 at 17:59 UTC')
self.assertEqual(json_resp.get('total_cost'), self.mock_get_order.ORDER['total_excl_tax'])
self.assertEqual(json_resp.get('status'), self.mock_get_order.ORDER['status'])
self.assertEqual(json_resp.get('billed_to'), {
'first_name': self.mock_get_order.ORDER['billing_address']['first_name'],
'last_name': self.mock_get_order.ORDER['billing_address']['last_name'],
'street1': self.mock_get_order.ORDER['billing_address']['line1'],
'street2': self.mock_get_order.ORDER['billing_address']['line2'],
'city': self.mock_get_order.ORDER['billing_address']['line4'],
'state': self.mock_get_order.ORDER['billing_address']['state'],
'postal_code': self.mock_get_order.ORDER['billing_address']['postcode'],
'country': self.mock_get_order.ORDER['billing_address']['country']['display_name']
})
self.assertEqual(len(json_resp.get('items')), num_items)
for item in json_resp.get('items'):
self.assertEqual(item, {
'unit_cost': self.mock_get_order.LINE['unit_price_excl_tax'],
'quantity': self.mock_get_order.LINE['quantity'],
'line_cost': self.mock_get_order.LINE['line_price_excl_tax'],
'line_desc': self.mock_get_order.LINE['description']
})
class mock_get_order(object): # pylint: disable=invalid-name
"""Mocks calls to EcommerceAPI.get_order. """
patch = None
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',
},
},
}
LINE = {
"title": "Honor Code Certificate for course Test Course",
"description": "Honor Code Certificate for course Test Course",
"status": "Paid",
"line_price_excl_tax": 40.0,
"quantity": 1,
"unit_price_excl_tax": 40.0
}
def __init__(self, **kwargs):
result = copy.deepcopy(self.ORDER)
result['lines'] = [copy.deepcopy(self.LINE) for _ in xrange(kwargs['num_items'])]
default_kwargs = {
'return_value': (
EcommerceApiTestMixin.ORDER_NUMBER,
OrderStatus.COMPLETE,
result,
)
}
default_kwargs.update(kwargs)
self.patch = mock.patch.object(EcommerceAPI, 'get_order', mock.Mock(**default_kwargs))
def __enter__(self):
self.patch.start()
return self.patch.new
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
self.patch.stop()
def test_show_receipt_json_multiple_items(self):
# Two different item types
PaidCourseRegistration.add_to_order(self.cart, self.course_key)
......@@ -997,13 +890,15 @@ class ShoppingCartViewsTests(ModuleStoreTestCase):
'unit_cost': 40,
'quantity': 1,
'line_cost': 40,
'line_desc': 'Registration for Course: Robot Super Course'
'line_desc': 'Registration for Course: Robot Super Course',
'course_key': unicode(self.course_key)
})
self.assertEqual(items[1], {
'unit_cost': 40,
'quantity': 1,
'line_cost': 40,
'line_desc': 'Honor Code Certificate for course Test Course'
'line_desc': 'Honor Code Certificate for course Test Course',
'course_key': unicode(self.verified_course_key)
})
def test_receipt_json_refunded(self):
......
......@@ -5,7 +5,7 @@ urlpatterns = patterns(
'shoppingcart.views',
url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here
url(r'^receipt/(?P<ordernum>[-\w]+)/$', 'show_receipt'),
url(r'^receipt/(?P<ordernum>[0-9]*)/$', 'show_receipt'),
url(r'^donation/$', 'donate', name='donation'),
url(r'^csv_report/$', 'csv_report', name='payment_csv_report'),
# These following URLs are only valid if the ENABLE_SHOPPING_CART feature flag is set
......
import logging
import datetime
import decimal
import dateutil
import pytz
from ipware.ip import get_ip
from django.db.models import Q
......@@ -13,9 +12,6 @@ from django.http import (
HttpResponseBadRequest, HttpResponseForbidden, Http404
)
from django.utils.translation import ugettext as _
from commerce.api import EcommerceAPI
from commerce.exceptions import InvalidConfigurationError, ApiError
from commerce.http import InternalRequestErrorResponse
from course_modes.models import CourseMode
from util.json_request import JsonResponse
from django.views.decorators.http import require_POST, require_http_methods
......@@ -824,91 +820,20 @@ def show_receipt(request, ordernum):
Displays a receipt for a particular order.
404 if order is not yet purchased or request.user != order.user
"""
is_json_request = 'application/json' in request.META.get('HTTP_ACCEPT', "")
try:
order = Order.objects.get(id=ordernum)
except (Order.DoesNotExist, ValueError):
if is_json_request:
return _get_external_order(request, ordernum)
else:
raise Http404('Order not found!')
except Order.DoesNotExist:
raise Http404('Order not found!')
if order.user != request.user or order.status not in ['purchased', 'refunded']:
raise Http404('Order not found!')
if is_json_request:
if 'application/json' in request.META.get('HTTP_ACCEPT', ""):
return _show_receipt_json(order)
else:
return _show_receipt_html(request, order)
def _get_external_order(request, order_number):
"""Get the order context from the external E-Commerce Service.
Get information about an order. This function makes a request to the E-Commerce Service to see if there is
order information that can be used to render a receipt for the user.
Args:
request (Request): The request for the the receipt.
order_number (str) : The order number.
Returns:
dict: A serializable dictionary of the receipt page context based on an order returned from the E-Commerce
Service.
"""
try:
api = EcommerceAPI()
order_number, order_status, order_data = api.get_order(request.user, order_number)
billing = order_data.get('billing_address', {})
country = billing.get('country', {})
# In order to get the date this order was paid, we need to check for payment sources, and associated
# transactions.
payment_dates = []
for source in order_data.get('sources', []):
for transaction in source.get('transactions', []):
payment_dates.append(dateutil.parser.parse(transaction['date_created']))
payment_date = sorted(payment_dates, reverse=True).pop()
order_info = {
'orderNum': order_number,
'currency': order_data['currency'],
'status': order_status,
'purchase_datetime': get_default_time_display(payment_date),
'total_cost': order_data['total_excl_tax'],
'billed_to': {
'first_name': billing.get('first_name', ''),
'last_name': billing.get('last_name', ''),
'street1': billing.get('line1', ''),
'street2': billing.get('line2', ''),
'city': billing.get('line4', ''), # 'line4' is the City, from the E-Commerce Service
'state': billing.get('state', ''),
'postal_code': billing.get('postcode', ''),
'country': country.get('display_name', ''),
},
'items': [
{
'quantity': item['quantity'],
'unit_cost': item['unit_price_excl_tax'],
'line_cost': item['line_price_excl_tax'],
'line_desc': item['description']
}
for item in order_data['lines']
]
}
return JsonResponse(order_info)
except InvalidConfigurationError:
msg = u"E-Commerce API not setup. Cannot request Order [{order_number}] for User [{user_id}] ".format(
user_id=request.user.id, order_number=order_number
)
log.debug(msg)
return JsonResponse(status=500, object={'error_message': msg})
except ApiError as err:
# The API will handle logging of the error.
return InternalRequestErrorResponse(err.message)
def _show_receipt_json(order):
"""Render the receipt page as JSON.
......@@ -946,7 +871,8 @@ def _show_receipt_json(order):
'quantity': item.qty,
'unit_cost': item.unit_cost,
'line_cost': item.line_cost,
'line_desc': item.line_desc
'line_desc': item.line_desc,
'course_key': unicode(getattr(item, 'course_id'))
}
for item in OrderItem.objects.filter(order=order).select_subclasses()
]
......
......@@ -27,7 +27,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from commerce.exceptions import ApiError
from commerce.tests import EcommerceApiTestMixin
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
......
......@@ -8,6 +8,7 @@ import decimal
import datetime
from collections import namedtuple
from pytz import UTC
from ipware.ip import get_ip
from django.conf import settings
......@@ -24,6 +25,7 @@ from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail
from ecommerce_api_client.exceptions import SlumberBaseException
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys import InvalidKeyError
from xmodule.modulestore.django import modulestore
......@@ -33,8 +35,7 @@ from edxmako.shortcuts import render_to_response, render_to_string
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
from commerce.api import EcommerceAPI
from commerce.exceptions import ApiError
from commerce import ecommerce_api_client
from course_modes.models import CourseMode
from student.models import CourseEnrollment
from student.views import reverification_info
......@@ -383,7 +384,7 @@ class PayAndVerifyView(View):
# get available payment processors
if unexpired_paid_course_mode.sku:
# transaction will be conducted via ecommerce service
processors = EcommerceAPI().get_processors(request.user)
processors = ecommerce_api_client(request.user).get_processors()
else:
# transaction will be conducted using legacy shopping cart
processors = [settings.CC_PROCESSOR_NAME]
......@@ -655,14 +656,14 @@ class PayAndVerifyView(View):
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()
api = ecommerce_api_client(user)
# Make an API call to create the order and retrieve the results
response_data = api.create_basket(user, course_mode.sku, processor)
response_data = api.create_basket(course_mode.sku, processor)
# Pass the payment parameters directly from the API response.
return response_data.get('payment_data')
except ApiError:
except SlumberBaseException:
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.exception('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
raise
......
/**
* View for the receipt page.
*/
var edx = edx || {};
(function ($, _, _s, Backbone) {
'use strict';
edx.commerce = edx.commerce || {};
edx.commerce.ReceiptView = Backbone.View.extend({
useEcommerceApi: true,
initialize: function () {
this.useEcommerceApi = !!($.url('?basket_id'));
_.bindAll(this, 'renderReceipt', 'renderError');
/* Mix non-conflicting functions from underscore.string (all but include, contains, and reverse) into
* the Underscore namespace.
*/
_.mixin(_s.exports());
this.render();
},
renderReceipt: function (data) {
var templateHtml = $("#receipt-tpl").html(),
context = {
platformName: this.$el.data('platform-name'),
verified: this.$el.data('verified').toLowerCase() === 'true'
};
// Add the receipt info to the template context
_.extend(context, {
receipt: this.receiptContext(data),
courseKey: this.getOrderCourseKey(data)
});
this.$el.html(_.template(templateHtml, context));
this.trackLinks();
},
renderError: function () {
// Display an error
$('#error-container').removeClass('hidden');
},
render: function () {
var self = this,
orderId = $.url('?basket_id') || $.url('?payment-order-num');
if (orderId) {
// Get the order details
self.getReceiptData(orderId).then(self.renderReceipt, self.renderError);
} else {
self.renderError();
}
},
trackLinks: function () {
var $verifyNowButton = $('#verify_now_button'),
$verifyLaterButton = $('#verify_later_button');
// Track a virtual pageview, for easy funnel reconstruction.
window.analytics.page('payment', 'receipt');
// Track the user's decision to verify immediately
window.analytics.trackLink($verifyNowButton, 'edx.bi.user.verification.immediate', {
category: 'verification'
});
// Track the user's decision to defer their verification
window.analytics.trackLink($verifyLaterButton, 'edx.bi.user.verification.deferred', {
category: 'verification'
});
},
/**
* Retrieve receipt data from Oscar (via LMS).
* @param {int} basketId The basket that was purchased.
* @return {object} JQuery Promise.
*/
getReceiptData: function (basketId) {
var urlFormat = this.useEcommerceApi ? '/commerce/baskets/%s/order/' : '/shoppingcart/receipt/%s/';
return $.ajax({
url: _.sprintf(urlFormat, basketId),
type: 'GET',
dataType: 'json'
}).retry({times: 5, timeout: 2000, statusCodes: [404]});
},
/**
* Construct the template context from data received
* from the E-Commerce API.
*
* @param {object} order Receipt data received from the server
* @return {object} Receipt template context.
*/
receiptContext: function (order) {
var self = this,
receiptContext;
if (this.useEcommerceApi) {
receiptContext = {
orderNum: order.number,
currency: order.currency,
purchasedDatetime: order.date_placed,
totalCost: self.formatMoney(order.total_excl_tax),
isRefunded: false,
billedTo: {
firstName: order.billing_address.first_name,
lastName: order.billing_address.last_name,
city: order.billing_address.city,
state: order.billing_address.state,
postalCode: order.billing_address.postcode,
country: order.billing_address.country
},
items: []
};
receiptContext.items = _.map(
order.lines,
function (line) {
return {
lineDescription: line.description,
cost: self.formatMoney(line.line_price_excl_tax)
};
}
);
} else {
receiptContext = {
orderNum: order.orderNum,
currency: order.currency,
purchasedDatetime: order.purchase_datetime,
totalCost: self.formatMoney(order.total_cost),
isRefunded: order.status === "refunded",
billedTo: {
firstName: order.billed_to.first_name,
lastName: order.billed_to.last_name,
city: order.billed_to.city,
state: order.billed_to.state,
postalCode: order.billed_to.postal_code,
country: order.billed_to.country
},
items: []
};
receiptContext.items = _.map(
order.items,
function (item) {
return {
lineDescription: item.line_desc,
cost: self.formatMoney(item.line_cost)
};
}
);
}
return receiptContext;
},
getOrderCourseKey: function (order) {
var length, items;
if (this.useEcommerceApi) {
length = order.lines.length;
for (var i = 0; i < length; i++) {
var line = order.lines[i],
attribute_values = _.filter(line.product.attribute_values, function (attribute) {
return attribute.name === 'course_key'
});
// This method assumes that all items in the order are related to a single course.
if (attribute_values.length > 0) {
return attribute_values[0]['value'];
}
}
} else {
items = _.filter(order.items, function (item) {
return item.course_key;
});
if (items.length > 0) {
return items[0].course_key;
}
}
return null;
},
formatMoney: function (moneyStr) {
return Number(moneyStr).toFixed(2);
}
});
new edx.commerce.ReceiptView({
el: $('#receipt-container')
});
})(jQuery, _, _.str, Backbone);
/*
* jquery.ajax-retry
* https://github.com/johnkpaul/jquery-ajax-retry
*
* Copyright (c) 2012 John Paul
* Licensed under the MIT license.
*/
(function(factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS
factory(require('jquery'));
} else {
// Browser globals
factory(jQuery);
}
})(function($) {
// enhance all ajax requests with our retry API
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
jqXHR.retry = function(opts) {
if(opts.timeout) {
this.timeout = opts.timeout;
}
if (opts.statusCodes) {
this.statusCodes = opts.statusCodes;
}
return this.pipe(null, pipeFailRetry(this, opts));
};
});
// generates a fail pipe function that will retry `jqXHR` `times` more times
function pipeFailRetry(jqXHR, opts) {
var times = opts.times;
var timeout = jqXHR.timeout;
// takes failure data as input, returns a new deferred
return function(input, status, msg) {
var ajaxOptions = this;
var output = new $.Deferred();
var retryAfter = jqXHR.getResponseHeader('Retry-After');
// whenever we do make this request, pipe its output to our deferred
function nextRequest() {
$.ajax(ajaxOptions)
.retry({times: times - 1, timeout: opts.timeout})
.pipe(output.resolve, output.reject);
}
if (times > 1 && (!jqXHR.statusCodes || $.inArray(input.status, jqXHR.statusCodes) > -1)) {
// implement Retry-After rfc
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.37
if (retryAfter) {
// it must be a date
if (isNaN(retryAfter)) {
timeout = new Date(retryAfter).getTime() - $.now();
// its a number in seconds
} else {
timeout = parseInt(retryAfter, 10) * 1000;
}
// ensure timeout is a positive number
if (isNaN(timeout) || timeout < 0) {
timeout = jqXHR.timeout;
}
}
if (timeout !== undefined){
setTimeout(nextRequest, timeout);
} else {
nextRequest();
}
} else {
// no times left, reject our deferred with the current arguments
output.rejectWith(this, arguments);
}
return output;
};
}
});
<%!
from django.utils.translation import ugettext as _
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="bodyclass">register verification-process step-requirements</%block>
<%block name="pagetitle">
${_("Receipt")}
</%block>
<%block name="header_extras">
<script type="text/template" id="receipt-tpl">
<%static:include path="commerce/receipt.underscore" />
</script>
</%block>
<%block name="js_extra">
<%static:js group='rwd_header_footer'/>
<script src="${static.url('js/vendor/jquery.ajax-retry.js')}"></script>
<script src="${static.url('js/vendor/underscore-min.js')}"></script>
<script src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
<script src="${static.url('js/src/tooltip_manager.js')}"></script>
<script src="${static.url('js/commerce/views/receipt_view.js')}"></script>
</%block>
<%block name="content">
<div id="error-container" class="hidden">
<div id="error" class="wrapper-msg wrapper-msg-activate">
<div class=" msg msg-activate">
<i class="msg-icon icon fa fa-exclamation-triangle" aria-hidden="true"></i>
<div class="msg-content">
<h3 class="title">
<span class="sr">${ _("Error:") }</span>
${ _("Error") }
</h3>
<div class="copy">
<p>${ _("Could not retrieve payment information") }</p>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<section class="wrapper carousel">
<div id="receipt-container" class="pay-and-verify" data-platform-name='${platform_name}' data-verified='${verified}'>
<h1>${_("Loading Order Data...")}</h1>
<span>${ _("Please wait while we retrieve your order details.") }</span>
</div>
</section>
</div>
</%block>
<div class="wrapper-content-main payment-confirmation-step">
<article class="content-main">
<h3 class="title">
<%= gettext( "Thank you! We have received your payment." ) %>
</h3>
<% if ( receipt ) { %>
<div class="list-info">
<div class="info-item payment-info">
<div class="copy">
<p><%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %></p>
</div>
<div class="wrapper-report">
<table class="report report-receipt">
<thead>
<tr>
<th scope="col" ><%- gettext( "Order No." ) %></th>
<th scope="col" ><%- gettext( "Description" ) %></th>
<th scope="col" ><%- gettext( "Date" ) %></th>
<th scope="col" ><%- gettext( "Amount" ) %></th>
</tr>
</thead>
<tbody>
<% for ( var i = 0; i < receipt.items.length; i++ ) { %>
<% if ( receipt.isRefunded ) { %>
<td><del><%- receipt.orderNum %></del></td>
<td><del><%- receipt.items[i].lineDescription %></del></td>
<td><del><%- receipt.purchasedDatetime %></del></td>
<td><del><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)</del></td>
<% } else { %>
<tr>
<td><%- receipt.orderNum %></td>
<td><%- receipt.items[i].lineDescription %></td>
<td><%- receipt.purchasedDatetime %></td>
<td><%- receipt.items[i].cost %> (<%- receipt.currency.toUpperCase() %>)</td>
</tr>
<% } %>
<% } %>
</tbody>
<tfoot>
<tr>
<th scope="row" class="total-label" colspan="1"><%- gettext( "Total" ) %></th>
<td class="total-value" colspan="3">
<span class="value-amount"><%- receipt.totalCost %></span>
<span class="value-currency">(<%- receipt.currency.toUpperCase() %>)</span>
</td>
</tr>
</tfoot>
</table>
<% if ( receipt.isRefunded ) { %>
<div class="msg msg-refunds">
<h4 class="title sr"><%- gettext( "Please Note" ) %>: </h4>
<div class="copy">
<p><%- gettext( "Crossed out items have been refunded." ) %></p>
</div>
</div>
<% } %>
</div>
<div class="copy">
<p><%- gettext( "Billed to" ) %>:
<span class="name-first"><%- receipt.billedTo.firstName %></span>
<span class="name-last"><%- receipt.billedTo.lastName %></span>
(<span class="address-city"><%- receipt.billedTo.city %></span>,
<span class="address-state"><%- receipt.billedTo.state %></span>
<span class="address-postalcode"><%- receipt.billedTo.postalCode %></span>
<span class="address-country"><%- receipt.billedTo.country.toUpperCase() %></span>)
</p>
</div>
</div>
</div>
<% } else { %>
<p class="no-content"><%- gettext( "No receipt available" ) %></p>
<% } %>
<nav class="nav-wizard is-ready">
<% if ( verified ) { %>
<a class="next action-primary right" href="/dashboard"><%- gettext( "Go to Dashboard" ) %></a>
<% } else { %>
<a id="verify_later_button" class="next action-secondary verify-later nav-link" href="/dashboard" data-tooltip="<%- _.sprintf( gettext( "If you don't verify your identity now, you can still explore your course from your dashboard. You will receive periodic reminders from %(platformName)s to verify your identity." ), { platformName: platformName } ) %>">
<%- gettext( "Want to confirm your identity later?" ) %>
</a>
<a id="verify_now_button"
class="next action-primary right"
href="<%- _.sprintf( '/verify_student/verify-now/%(courseKey)s/', { courseKey: courseKey } ) %>"
>
<%- gettext( "Verify Now" ) %>
</a>
<% } %>
</nav>
</article>
</div>
......@@ -5,8 +5,8 @@
</h3>
<% if ( receipt ) { %>
<ul class="list-info">
<li class="info-item payment-info">
<div class="list-info">
<div class="info-item payment-info">
<div class="copy">
<p><%- gettext( "Please print this page for your records; it serves as your receipt. You will also receive an email with the same information." ) %></p>
</div>
......@@ -43,7 +43,7 @@
<tfoot>
<tr>
<th scope="row" class="total-label" colspan="1"><%- gettext( "Total" ) %></th>
<td claass="total-value" colspan="3">
<td class="total-value" colspan="3">
<span class="value-amount"><%- receipt.totalCost %></span>
<span class="value-currency">(<%- receipt.currency.toUpperCase() %>)</span>
</td>
......@@ -71,8 +71,8 @@
<span class="address-country"><%- receipt.billedTo.country.toUpperCase() %></span>)
</p>
</div>
</li>
</ul>
</div>
</div>
<% } else { %>
<p class="no-content"><%- gettext( "No receipt available" ) %></p>
<% } %>
......
......@@ -48,6 +48,7 @@ git+https://github.com/edx/edx-lint.git@8bf82a32ecb8598c415413df66f5232ab8d974e9
-e git+https://github.com/edx/xblock-utils.git@581ed636c862b286002bb9a3724cc883570eb54c#egg=xblock-utils
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
-e git+https://github.com/edx/edx-reverification-block.git@5da515ef229e73a137d366beb05ea4aea5881960#egg=edx-reverification-block
git+https://github.com/edx/ecommerce-api-client.git@0.2.0#egg=ecommerce-api-client
# Third Party XBlocks
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
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