Commit 3d16cc96 by Vedran Karacic

Client side checkout basket

* Generalizing (old) basket page so that it can accept
  different type of products and supports multi-line.

* Redesign of the new client side checkout basket,
  custom form validation, CyberSource field signing and
  accessibility compliance.

SOL-2113
parent fadca204
...@@ -279,6 +279,19 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -279,6 +279,19 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
) )
) )
def test_non_seat_product(self):
"""Verify the basket accepts non-seat product types."""
title = 'Test Product 123'
description = 'All hail the test product.'
product = factories.ProductFactory(title=title, description=description)
self.create_basket_and_add_product(product)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
line_data = response.context['formset_lines_data'][0][1]
self.assertEqual(line_data['product_title'], title)
self.assertEqual(line_data['product_description'], description)
def test_enrollment_code_seat_type(self): def test_enrollment_code_seat_type(self):
"""Verify the correct seat type attribute is retrieved.""" """Verify the correct seat type attribute is retrieved."""
course = CourseFactory() course = CourseFactory()
...@@ -290,7 +303,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -290,7 +303,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(response.context['is_bulk_purchase']) self.assertFalse(response.context['show_voucher_form'])
# Enable enrollment codes # Enable enrollment codes
self.site.siteconfiguration.enable_enrollment_codes = True self.site.siteconfiguration.enable_enrollment_codes = True
...@@ -298,7 +311,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -298,7 +311,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(response.context['is_bulk_purchase']) self.assertFalse(response.context['show_voucher_form'])
line_data = response.context['formset_lines_data'][0][1] line_data = response.context['formset_lines_data'][0][1]
self.assertEqual(line_data['seat_type'], _(enrollment_code.attr.seat_type.capitalize())) self.assertEqual(line_data['seat_type'], _(enrollment_code.attr.seat_type.capitalize()))
...@@ -370,7 +383,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -370,7 +383,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
line_data = response.context['formset_lines_data'][0][1] line_data = response.context['formset_lines_data'][0][1]
self.assertEqual(line_data['benefit_value'], format_benefit_value(benefit)) self.assertEqual(line_data['benefit_value'], format_benefit_value(benefit))
self.assertEqual(line_data['seat_type'], _(seat.attr.certificate_type.capitalize())) self.assertEqual(line_data['seat_type'], _(seat.attr.certificate_type.capitalize()))
self.assertEqual(line_data['course_name'], self.course.name) self.assertEqual(line_data['product_title'], self.course.name)
self.assertFalse(line_data['enrollment_code']) self.assertFalse(line_data['enrollment_code'])
self.assertEqual(response.context['payment_processors'][0].NAME, DummyProcessor.NAME) self.assertEqual(response.context['payment_processors'][0].NAME, DummyProcessor.NAME)
...@@ -446,7 +459,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -446,7 +459,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
self.create_basket_and_add_product(seat) self.create_basket_and_add_product(seat)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['is_verification_required'], ver_req) self.assertEqual(response.context['display_verification_message'], ver_req)
def test_verification_attribute_missing(self): def test_verification_attribute_missing(self):
""" Verify the variable for verification requirement is False when the attribute is missing. """ """ Verify the variable for verification requirement is False when the attribute is missing. """
...@@ -455,7 +468,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms ...@@ -455,7 +468,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
self.create_basket_and_add_product(seat) self.create_basket_and_add_product(seat)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['is_verification_required'], False) self.assertEqual(response.context['display_verification_message'], False)
@override_flag(CLIENT_SIDE_CHECKOUT_FLAG_NAME, active=True) @override_flag(CLIENT_SIDE_CHECKOUT_FLAG_NAME, active=True)
def test_client_side_checkout(self): def test_client_side_checkout(self):
......
...@@ -57,6 +57,8 @@ def prepare_basket(request, product, voucher=None): ...@@ -57,6 +57,8 @@ def prepare_basket(request, product, voucher=None):
def get_basket_switch_data(product): def get_basket_switch_data(product):
product_class_name = product.get_product_class().name product_class_name = product.get_product_class().name
structure = product.structure
switch_link_text = None
if product_class_name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME: if product_class_name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME:
switch_link_text = _('Click here to just purchase an enrollment for yourself') switch_link_text = _('Click here to just purchase an enrollment for yourself')
......
from __future__ import unicode_literals from __future__ import unicode_literals
from datetime import datetime
import logging import logging
import waffle import waffle
...@@ -96,42 +97,71 @@ class BasketSummaryView(BasketView): ...@@ -96,42 +97,71 @@ class BasketSummaryView(BasketView):
seat_type = get_certificate_type_display_value(product.attr.seat_type) seat_type = get_certificate_type_display_value(product.attr.seat_type)
return seat_type return seat_type
def _get_course_data(self, product):
"""
Return course data.
Args:
product (Product): A product that has course_key as attribute (seat or bulk enrollment coupon)
Returns:
Dictionary containing course name, course key, course image URL and description.
"""
course_key = CourseKey.from_string(product.attr.course_key)
course_name = None
image_url = None
short_description = None
try:
course = get_course_info_from_catalog(self.request.site, course_key)
try:
image_url = course['image']['src']
except (KeyError, TypeError):
image_url = ''
short_description = course.get('short_description', '')
course_name = course.get('title', '')
except (ConnectionError, SlumberBaseException, Timeout):
logger.exception('Failed to retrieve data from Catalog Service for course [%s].', course_key)
return {
'product_title': course_name,
'course_key': course_key,
'image_url': image_url,
'product_description': short_description,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(BasketSummaryView, self).get_context_data(**kwargs) context = super(BasketSummaryView, self).get_context_data(**kwargs)
formset = context.get('formset', []) formset = context.get('formset', [])
lines = context.get('line_list', []) lines = context.get('line_list', [])
lines_data = [] lines_data = []
is_verification_required = is_bulk_purchase = False display_verification_message = False
show_voucher_form = True
switch_link_text = partner_sku = '' switch_link_text = partner_sku = ''
basket = self.request.basket basket = self.request.basket
site = self.request.site site = self.request.site
site_configuration = site.siteconfiguration site_configuration = site.siteconfiguration
for line in lines: for line in lines:
course_key = CourseKey.from_string(line.product.attr.course_key) product_class_name = line.product.get_product_class().name
course_name = None if product_class_name == 'Seat':
image_url = None line_data = self._get_course_data(line.product)
short_description = None if (getattr(line.product.attr, 'id_verification_required', False) and
try: line.product.attr.certificate_type != 'credit'):
course = get_course_info_from_catalog(self.request.site, course_key) display_verification_message = True
try: elif product_class_name == 'Enrollment Code':
image_url = course['image']['src'] line_data = self._get_course_data(line.product)
except (KeyError, TypeError): show_voucher_form = False
image_url = '' else:
short_description = course.get('short_description', '') line_data = {
course_name = course.get('title', '') 'product_title': line.product.title,
except (ConnectionError, SlumberBaseException, Timeout): 'image_url': None,
logger.exception('Failed to retrieve data from Catalog Service for course [%s].', course_key) 'product_description': line.product.description
}
# TODO: handle these links for multi-line baskets.
if self.request.site.siteconfiguration.enable_enrollment_codes: if self.request.site.siteconfiguration.enable_enrollment_codes:
# Get variables for the switch link that toggles from enrollment codes and seat. # Get variables for the switch link that toggles from enrollment codes and seat.
switch_link_text, partner_sku = get_basket_switch_data(line.product) switch_link_text, partner_sku = get_basket_switch_data(line.product)
if line.product.get_product_class().name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME:
is_bulk_purchase = True
# Iterate on message storage so all messages are marked as read.
# This will hide the success messages when a user updates the quantity
# for an item in the basket.
list(messages.get_messages(self.request))
if line.has_discount: if line.has_discount:
benefit = basket.applied_offers().values()[0].benefit benefit = basket.applied_offers().values()[0].benefit
...@@ -139,62 +169,70 @@ class BasketSummaryView(BasketView): ...@@ -139,62 +169,70 @@ class BasketSummaryView(BasketView):
else: else:
benefit_value = None benefit_value = None
lines_data.append({ line_data.update({
'seat_type': self._determine_seat_type(line.product), 'seat_type': self._determine_seat_type(line.product),
'course_name': course_name,
'course_key': course_key,
'image_url': image_url,
'course_short_description': short_description,
'benefit_value': benefit_value, 'benefit_value': benefit_value,
'enrollment_code': line.product.get_product_class().name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME, 'enrollment_code': line.product.get_product_class().name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME,
'line': line, 'line': line,
}) })
lines_data.append(line_data)
user = self.request.user course_key = lines_data[0].get('course_key') if len(lines) == 1 else None
context.update({ user = self.request.user
'analytics_data': prepare_analytics_data( context.update({
user, 'analytics_data': prepare_analytics_data(
self.request.site.siteconfiguration.segment_key, user,
unicode(course_key) self.request.site.siteconfiguration.segment_key,
), unicode(course_key)
'enable_client_side_checkout': False, ),
}) 'enable_client_side_checkout': False,
})
if site_configuration.client_side_payment_processor \ payment_processors = site_configuration.get_payment_processors()
and waffle.flag_is_active(self.request, CLIENT_SIDE_CHECKOUT_FLAG_NAME): if site_configuration.client_side_payment_processor \
payment_processor_class = site_configuration.get_client_side_payment_processor_class() and waffle.flag_is_active(self.request, CLIENT_SIDE_CHECKOUT_FLAG_NAME):
payment_processor_class = site_configuration.get_client_side_payment_processor_class()
if payment_processor_class:
payment_processor = payment_processor_class(site) if payment_processor_class:
payment_processor = payment_processor_class(site)
context.update({
'enable_client_side_checkout': True, today = datetime.today()
'payment_form': PaymentForm(user=user, initial={'basket': basket}, label_suffix=''), context.update({
'payment_url': payment_processor.client_side_payment_url, 'enable_client_side_checkout': True,
}) 'client_side_payment_processor_name': payment_processor.NAME,
else: 'paypal_enabled': 'paypal' in (p.NAME for p in payment_processors),
msg = 'Unable to load client-side payment processor [{processor}] for ' \ 'months': range(1, 13),
'site configuration [{sc}]'.format(processor=site_configuration.client_side_payment_processor, 'payment_form': PaymentForm(user=user, initial={'basket': basket}, label_suffix=''),
sc=site_configuration.id) 'payment_url': payment_processor.client_side_payment_url,
raise SiteConfigurationError(msg) # Assumption is that the credit card duration is 15 years
'years': range(today.year, today.year + 16)
# Check product attributes to determine if ID verification is required for this basket })
try: else:
is_verification_required = line.product.attr.id_verification_required \ msg = 'Unable to load client-side payment processor [{processor}] for ' \
and line.product.attr.certificate_type != 'credit' 'site configuration [{sc}]'.format(processor=site_configuration.client_side_payment_processor,
except AttributeError: sc=site_configuration.id)
pass raise SiteConfigurationError(msg)
# Total benefit displayed in price summary.
# Currently only one voucher per basket is supported.
applied_voucher = basket.vouchers.first()
total_benefit = (
format_benefit_value(applied_voucher.offers.first().benefit)
if applied_voucher else None
)
context.update({ context.update({
'total_benefit': total_benefit,
'free_basket': context['order_total'].incl_tax == 0, 'free_basket': context['order_total'].incl_tax == 0,
'payment_processors': site_configuration.get_payment_processors(), 'payment_processors': payment_processors,
'homepage_url': get_lms_url(''), 'homepage_url': get_lms_url(''),
'formset_lines_data': zip(formset, lines_data), 'formset_lines_data': zip(formset, lines_data),
'is_verification_required': is_verification_required, 'display_verification_message': display_verification_message,
'min_seat_quantity': 1, 'min_seat_quantity': 1,
'is_bulk_purchase': is_bulk_purchase, 'show_voucher_form': show_voucher_form,
'switch_link_text': switch_link_text, 'switch_link_text': switch_link_text,
'partner_sku': partner_sku, 'partner_sku': partner_sku,
'course_key': course_key
}) })
return context return context
......
...@@ -54,7 +54,6 @@ class FreeCheckoutViewTests(TestCase): ...@@ -54,7 +54,6 @@ class FreeCheckoutViewTests(TestCase):
""" Verify redirect to the receipt page. """ """ Verify redirect to the receipt page. """
self.prepare_basket(0) self.prepare_basket(0)
self.assertEqual(Order.objects.count(), 0) self.assertEqual(Order.objects.count(), 0)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1) self.assertEqual(Order.objects.count(), 1)
...@@ -71,7 +70,7 @@ class FreeCheckoutViewTests(TestCase): ...@@ -71,7 +70,7 @@ class FreeCheckoutViewTests(TestCase):
self.toggle_ecommerce_receipt_page(False) self.toggle_ecommerce_receipt_page(False)
self.prepare_basket(0) self.prepare_basket(0)
self.assertEqual(Order.objects.count(), 0) self.assertEqual(Order.objects.count(), 0)
receipt_page = self.site.siteconfiguration.build_lms_url('/commerce/checkout/receipt') receipt_page = self.site.siteconfiguration.build_lms_url('/commerce/checkout/receipt/')
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1) self.assertEqual(Order.objects.count(), 1)
......
...@@ -51,7 +51,7 @@ def get_receipt_page_url(site_configuration, order_number=None, override_url=Non ...@@ -51,7 +51,7 @@ def get_receipt_page_url(site_configuration, order_number=None, override_url=Non
base_url = site_configuration.build_ecommerce_url(reverse('checkout:receipt')) base_url = site_configuration.build_ecommerce_url(reverse('checkout:receipt'))
params = urllib.urlencode({'order_number': order_number}) if order_number else '' params = urllib.urlencode({'order_number': order_number}) if order_number else ''
else: else:
base_url = site_configuration.build_lms_url('/commerce/checkout/receipt') base_url = site_configuration.build_lms_url('/commerce/checkout/receipt/')
params = urllib.urlencode({'orderNum': order_number}) if order_number else '' params = urllib.urlencode({'orderNum': order_number}) if order_number else ''
return '{base_url}{params}'.format( return '{base_url}{params}'.format(
......
...@@ -8,13 +8,18 @@ from django.core.exceptions import ValidationError ...@@ -8,13 +8,18 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from oscar.core.loading import get_model from oscar.core.loading import get_model
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, HTML, Layout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
def country_choices(): def country_choices():
""" Returns a tuple of tuples, each containing an ISO 3166 country code. """ """ Returns a tuple of tuples, each containing an ISO 3166 country code. """
return ((country.alpha2, country.name) for country in pycountry.countries) countries = [(country.alpha2, country.name) for country in pycountry.countries]
countries.insert(0, ('', _('<Choose country>')))
return countries
class PaymentForm(forms.Form): class PaymentForm(forms.Form):
...@@ -27,13 +32,66 @@ class PaymentForm(forms.Form): ...@@ -27,13 +32,66 @@ class PaymentForm(forms.Form):
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
super(PaymentForm, self).__init__(*args, **kwargs) super(PaymentForm, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.layout = Layout(
Div('basket'),
Div(
Div('first_name'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('last_name'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('address_line1'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('address_line2'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('city'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('state'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('country'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
),
Div(
Div('postal_code'),
HTML('<p class="help-block"></p>'),
css_class='form-item col-md-6'
)
)
self.fields['basket'].queryset = self.fields['basket'].queryset.filter(owner=user) self.fields['basket'].queryset = self.fields['basket'].queryset.filter(owner=user)
for bound_field in self:
# https://www.w3.org/WAI/tutorials/forms/validation/#validating-required-input
if hasattr(bound_field, 'field') and bound_field.field.required:
# Translators: This is a string added next to the name of the required
# fields on the payment form. For example, the first name field is
# required, so this would read "First name (required)".
self.fields[bound_field.name].label = _('{label} (required)'.format(label=bound_field.label))
bound_field.field.widget.attrs['required'] = 'required'
bound_field.field.widget.attrs['aria-required'] = 'true'
basket = forms.ModelChoiceField(queryset=Basket.objects.all(), widget=forms.HiddenInput()) basket = forms.ModelChoiceField(queryset=Basket.objects.all(), widget=forms.HiddenInput())
first_name = forms.CharField(max_length=60, label=_('First Name')) first_name = forms.CharField(max_length=60, label=_('First Name'))
last_name = forms.CharField(max_length=60, label=_('Last Name')) last_name = forms.CharField(max_length=60, label=_('Last Name'))
address_line1 = forms.CharField(max_length=29, label=_('Address')) address_line1 = forms.CharField(max_length=29, label=_('Address'))
address_line2 = forms.CharField(max_length=29, required=False, label=_('Address (continued)')) address_line2 = forms.CharField(max_length=29, required=False, label=_('Suite/Apartment Number'))
city = forms.CharField(max_length=32, label=_('City')) city = forms.CharField(max_length=32, label=_('City'))
state = forms.CharField(max_length=60, required=False, label=_('State/Province')) state = forms.CharField(max_length=60, required=False, label=_('State/Province'))
postal_code = forms.CharField(max_length=10, required=False, label=_('Zip/Postal Code')) postal_code = forms.CharField(max_length=10, required=False, label=_('Zip/Postal Code'))
......
...@@ -182,7 +182,7 @@ class PaypalTests(PaypalMixin, PaymentProcessorTestCaseMixin, TestCase): ...@@ -182,7 +182,7 @@ class PaypalTests(PaypalMixin, PaymentProcessorTestCaseMixin, TestCase):
Ensures that when the otto_receipt_page waffle switch is disabled, the processor uses the LMS receipt page. Ensures that when the otto_receipt_page waffle switch is disabled, the processor uses the LMS receipt page.
""" """
self.site.siteconfiguration.enable_otto_receipt_page = False self.site.siteconfiguration.enable_otto_receipt_page = False
assert self._get_receipt_url() == self.site.siteconfiguration.build_lms_url('/commerce/checkout/receipt') assert self._get_receipt_url() == self.site.siteconfiguration.build_lms_url('/commerce/checkout/receipt/')
@httpretty.activate @httpretty.activate
@mock.patch('ecommerce.extensions.payment.processors.paypal.paypalrestsdk.Payment') @mock.patch('ecommerce.extensions.payment.processors.paypal.paypalrestsdk.Payment')
......
...@@ -180,7 +180,7 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa ...@@ -180,7 +180,7 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket, self.basket,
billing_address=self.billing_address, billing_address=self.billing_address,
) )
with mock.patch.object(CybersourceInterstitialView, 'validate_notification', side_effect=error_class): with mock.patch.object(self.view, 'validate_notification', side_effect=error_class):
response = self.client.post(self.path, notification) response = self.client.post(self.path, notification)
self.assertRedirects(response, self.get_full_url(reverse('payment_error'))) self.assertRedirects(response, self.get_full_url(reverse('payment_error')))
...@@ -201,9 +201,14 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa ...@@ -201,9 +201,14 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket, self.basket,
billing_address=self.billing_address, billing_address=self.billing_address,
) )
with mock.patch.object(CybersourceInterstitialView, 'validate_notification', side_effect=error_class): with mock.patch.object(self.view, 'validate_notification', side_effect=error_class):
response = self.client.post(self.path, notification) response = self.client.post(self.path, notification)
self.assertRedirects(response, self.get_full_url(path=reverse('basket:summary')), status_code=302) self.assertRedirects(
response,
self.get_full_url(path=reverse('basket:summary')),
status_code=302,
fetch_redirect_response=False
)
def test_successful_order(self): def test_successful_order(self):
""" Verify the view redirects to the Receipt page when the Order has been successfully placed. """ """ Verify the view redirects to the Receipt page when the Order has been successfully placed. """
...@@ -224,6 +229,6 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa ...@@ -224,6 +229,6 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket, self.basket,
billing_address=self.billing_address, billing_address=self.billing_address,
) )
with mock.patch.object(CybersourceInterstitialView, 'create_order', side_effect=Exception): with mock.patch.object(self.view, 'create_order', side_effect=Exception):
response = self.client.post(self.path, notification) response = self.client.post(self.path, notification)
self.assertRedirects(response, self.get_full_url(path=reverse('payment_error')), status_code=302) self.assertRedirects(response, self.get_full_url(path=reverse('payment_error')), status_code=302)
...@@ -6,61 +6,8 @@ require([ ...@@ -6,61 +6,8 @@ require([
BasketPage) { BasketPage) {
'use strict'; 'use strict';
/**
* Configure the payment form event handlers.
*/
function initializePaymentForm() {
var signingUrl,
$paymentForm = $('.payment-form');
if ($paymentForm.length < 1) {
return;
}
signingUrl = $paymentForm.data('signing-url');
$paymentForm.submit(function (event) {
var $signedFields = $('input,select', $paymentForm).not('.pci-field');
// Post synchronously since we need the returned data.
$.ajax({
type: 'POST',
url: signingUrl,
data: $signedFields.serialize(),
async: false,
success: function (data) {
var formData = data.form_fields;
// Disable the fields on the form so they are not posted since their names are not what is
// expected by CyberSource. Instead post add the parameters from the server to the form,
// and post them.
$signedFields.attr('disabled', 'disabled');
for (var key in formData) {
if (formData.hasOwnProperty(key)) {
$paymentForm.append(
'<input type="hidden" name="' + key + '" value="' + formData[key] + '" />'
);
}
}
},
error: function (jqXHR, textStatus, errorThrown) {
// TODO Handle errors. Ideally the form should be validated in JavaScript
// before it is submitted.
console.log(jqXHR);
console.log(textStatus);
console.log(errorThrown);
// Don't allow the form to submit.
event.stopPropagation();
}
});
});
}
$(document).ready(function () { $(document).ready(function () {
BasketPage.onReady(); BasketPage.onReady();
initializePaymentForm();
}); });
} }
); );
...@@ -7,12 +7,14 @@ define([ ...@@ -7,12 +7,14 @@ define([
'underscore', 'underscore',
'underscore.string', 'underscore.string',
'utils/utils', 'utils/utils',
'utils/credit_card',
'js-cookie' 'js-cookie'
], ],
function ($, function ($,
_, _,
_s, _s,
Utils, Utils,
CreditCardUtils,
Cookies Cookies
) { ) {
'use strict'; 'use strict';
...@@ -37,7 +39,6 @@ define([ ...@@ -37,7 +39,6 @@ define([
success: onSuccess, success: onSuccess,
error: onFail error: onFail
}); });
}, },
hideVoucherForm = function() { hideVoucherForm = function() {
$('#voucher_form_container').hide(); $('#voucher_form_container').hide();
...@@ -67,11 +68,74 @@ define([ ...@@ -67,11 +68,74 @@ define([
$form.appendTo('body').submit(); $form.appendTo('body').submit();
}, },
appendValidationErrorMsg = function(event, field, msg) {
field.find('~.help-block').append('<span>' + msg + '</span>');
field.focus();
event.preventDefault();
},
cardHolderInfoValidation = function (event) {
var requiredFields = [
'input[name=first_name]',
'input[name=last_name]',
'input[name=address_line1]',
'input[name=city]',
'select[name=country]'
];
_.each(requiredFields, function(field) {
if ($(field).val() === '') {
event.preventDefault();
$(field).parentsUntil('form-item').find('~.help-block').append(
'<span>This field is required</span>'
);
}
});
// Focus the first element that has an error message.
$('.help-block > span').first().parentsUntil('fieldset').last().find('input').focus();
},
cardInfoValidation = function (event) {
var cardType,
currentMonth = new Date().getMonth(),
currentYear = new Date().getFullYear(),
cardNumber = $('input[name=card_number]').val(),
cvnNumber = $('input[name=card_cvn]').val(),
cardExpiryMonth = $('select[name=card_expiry_month]').val(),
cardExpiryYear = $('select[name=card_expiry_year]').val(),
cardNumberField = $('input[name=card_number]'),
cvnNumberField = $('input[name=card_cvn]'),
cardExpiryMonthField = $('select[name=card_expiry_month]'),
cardExpiryYearField = $('select[name=card_expiry_year]');
cardType = CreditCardUtils.getCreditCardType(cardNumber);
if (!CreditCardUtils.isValidCardNumber(cardNumber)) {
appendValidationErrorMsg(event, cardNumberField, 'Invalid card number');
} else if (typeof cardType === 'undefined') {
appendValidationErrorMsg(event, cardNumberField, 'Unsupported card type');
} else if (cvnNumber.length !== cardType.cvnLength || !Number.isInteger(Number(cvnNumber))) {
appendValidationErrorMsg(event, cvnNumberField, 'Invalid CVN');
}
if (!Number.isInteger(Number(cardExpiryMonth)) ||
Number(cardExpiryMonth) > 12 || Number(cardExpiryMonth) < 1) {
appendValidationErrorMsg(event, cardExpiryMonthField, 'Invalid month');
} else if (!Number.isInteger(Number(cardExpiryYear)) || Number(cardExpiryYear) < currentYear) {
appendValidationErrorMsg(event, cardExpiryYearField, 'Invalid year');
} else if (Number(cardExpiryMonth) < currentMonth && Number(cardExpiryYear) === currentYear) {
appendValidationErrorMsg(event, cardExpiryMonthField, 'Card expired');
}
},
onReady = function() { onReady = function() {
var $paymentButtons = $('.payment-buttons'), var $paymentButtons = $('.payment-buttons'),
basketId = $paymentButtons.data('basket-id'); basketId = $paymentButtons.data('basket-id'),
cardNumber,
iconPath = '/static/images/credit_cards/',
card;
$('#voucher_form_link a').on('click', function(event) { $('#voucher_form_link').on('click', function(event) {
event.preventDefault(); event.preventDefault();
showVoucherForm(); showVoucherForm();
}); });
...@@ -81,6 +145,33 @@ define([ ...@@ -81,6 +145,33 @@ define([
hideVoucherForm(); hideVoucherForm();
}); });
$('#card-number-input').on('input', function() {
cardNumber = $('#card-number-input').val().replace(/\s+/g, '');
if (cardNumber.length > 12) {
card = CreditCardUtils.getCreditCardType(cardNumber);
if (typeof card !== 'undefined') {
$('.card-type-icon').attr(
'src',
iconPath + card.name + '.png'
);
$('input[name=card_type]').val(card.type);
} else {
$('.card-type-icon').attr('src', '');
$('input[name=card_type]').val('');
}
}
});
$('#payment-button').click(function(e) {
_.each($('.help-block'), function(errorMsg) {
$(errorMsg).empty(); // Clear existing validation error messages.
});
cardInfoValidation(e);
cardHolderInfoValidation(e);
});
$paymentButtons.find('.payment-button').click(function (e) { $paymentButtons.find('.payment-button').click(function (e) {
var $btn = $(e.target), var $btn = $(e.target),
deferred = new $.Deferred(), deferred = new $.Deferred(),
...@@ -127,11 +218,12 @@ define([ ...@@ -127,11 +218,12 @@ define([
return { return {
appendToForm: appendToForm, appendToForm: appendToForm,
cardInfoValidation: cardInfoValidation,
checkoutPayment: checkoutPayment, checkoutPayment: checkoutPayment,
hideVoucherForm: hideVoucherForm, hideVoucherForm: hideVoucherForm,
onSuccess: onSuccess,
onFail: onFail, onFail: onFail,
onReady: onReady, onReady: onReady,
onSuccess: onSuccess,
showVoucherForm: showVoucherForm, showVoucherForm: showVoucherForm,
}; };
} }
......
/**
* CyberSource payment processor specific actions.
*/
require(['jquery'], function($) {
'use strict';
function initializePaymentForm() {
var signingUrl,
$paymentForm = $('.payment-form');
if ($paymentForm.length < 1) {
return;
}
signingUrl = $paymentForm.data('signing-url');
$paymentForm.submit(function (event) {
var $signedFields = $('input,select', $paymentForm).not('.pci-field');
// Format the date to a format that CyberSource accepts (MM-YYYY).
var cardExpiryMonth = $('select[name=card_expiry_month]').val(),
cardExpiryYear = $('select[name=card_expiry_year]').val();
$('input[name=card_expiry_date]').val(cardExpiryMonth + '-' + cardExpiryYear);
// Post synchronously since we need the returned data.
$.ajax({
type: 'POST',
url: signingUrl,
data: $signedFields.serialize(),
async: false,
success: function (data) {
var formData = data.form_fields;
// Disable the fields on the form so they are not posted since their names are not what is
// expected by CyberSource. Instead post add the parameters from the server to the form,
// and post them.
$signedFields.attr('disabled', 'disabled');
for (var key in formData) {
if (formData.hasOwnProperty(key)) {
$paymentForm.append(
'<input type="hidden" name="' + key + '" value="' + formData[key] + '" />'
);
}
}
},
error: function (jqXHR, textStatus, errorThrown) {
// TODO Handle errors. Ideally the form should be validated in JavaScript
// before it is submitted.
console.log(jqXHR);
console.log(textStatus);
console.log(errorThrown);
// Don't allow the form to submit.
event.stopPropagation();
}
});
});
}
$(document).ready(function () {
initializePaymentForm();
});
});
define([
'utils/credit_card'
],
function (CreditCardUtils) {
'use strict';
describe('CreditCardUtils', function () {
var validCardList = [
{'number': '378282246310005', 'name': 'amex', 'type': '003'},
{'number': '30569309025904', 'name': 'diners', 'type': '005'},
{'number': '6011111111111117', 'name': 'discover', 'type': '004'},
{'number': '3530111333300000', 'name': 'jcb', 'type': '007'},
{'number': '5105105105105100', 'name': 'mastercard', 'type': '002'},
{'number': '4111111111111111', 'name': 'visa', 'type': '001'},
{'number': '6759649826438453', 'name': 'maestro', 'type': '042'}
];
describe('isValidCreditCard', function() {
it('should return true for the valid credit cards', function() {
_.each(validCardList, function(cardNum) {
expect(CreditCardUtils.isValidCardNumber(cardNum.number)).toEqual(true);
});
});
it('should return false for the invalid credit cards', function() {
var invalidCards = ['3782831abc0005', '305699909025904', '00000'];
_.each(invalidCards, function(cardNum) {
expect(CreditCardUtils.isValidCardNumber(cardNum)).toEqual(false);
});
});
});
describe('getCreditCardType', function() {
it('should recognize the right card', function() {
_.each(validCardList, function(card) {
var cardType = CreditCardUtils.getCreditCardType(card.number);
expect(cardType.name).toEqual(card.name);
expect(cardType.type).toEqual(card.type);
});
});
it('should not return anything for unrecognized credit cards', function() {
var invalidNum = '123123123';
var invalidCard = CreditCardUtils.getCreditCardType(invalidNum);
expect(typeof invalidCard).toEqual('undefined');
});
});
});
}
);
define([],function () {
'use strict';
return {
/**
* Most performant Luhn check for credit card number validity.
* https://jsperf.com/credit-card-validator/7
*/
isValidCardNumber: function(cardNumber) {
var len = cardNumber.length,
mul = 0,
prodArr = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [0, 2, 4, 6, 8, 1, 3, 5, 7, 9]],
sum = 0;
while (len--) {
sum += prodArr[mul][parseInt(cardNumber.charAt(len), 10)];
mul ^= 1;
}
return sum % 10 === 0 && sum > 0;
},
/**
* Get the credit card type based on the card's number.
*
* @param (string) - The credit card number.
*
* @returns (object) - The credit card type name and coresponding 3-number CyberSource card ID.
*/
getCreditCardType: function(cardNumber) {
var matchers = {
amex: {
regex: /^3[47]\d{13}$/,
cybersourceTypeId: '003',
cvnLength: 4
},
diners: {
regex: /^3(?:0[0-59]|[689]\d)\d{11}$/,
cybersourceTypeId: '005',
cvnLength: 3
},
discover: {
regex: /^(6011\d{2}|65\d{4}|64[4-9]\d{3}|62212[6-9]|6221[3-9]\d|622[2-8]\d{2}|6229[01]\d|62292[0-5])\d{10,13}$/, // jshint ignore:line
cybersourceTypeId: '004',
cvnLength: 3
},
jcb: {
regex: /^(?:2131|1800|35\d{3})\d{11}$/,
cybersourceTypeId: '007',
cvnLength: 4
},
maestro: {
regex: /^(5[06789]|6\d)[0-9]{10,17}$/,
cybersourceTypeId: '042',
cvnLength: 3
},
mastercard: {
regex: /^(5[1-5]\d{2}|222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)\d{12}$/,
cybersourceTypeId: '002',
cvnLength: 3
},
visa: {
regex: /^(4\d{12}?(\d{3})?)$/,
cybersourceTypeId: '001',
cvnLength: 3
}
};
for (var key in matchers) {
if (matchers[key].regex.test(cardNumber)) {
return {
'name': key,
'type': matchers[key].cybersourceTypeId,
'cvnLength': matchers[key].cvnLength
};
}
}
}
};
}
);
.basket { .basket {
.close {
margin-top: -20px;
}
.container { .container {
padding: 60px 20px 30px 20px; padding: 60px 20px 30px 20px;
...@@ -12,6 +16,7 @@ ...@@ -12,6 +16,7 @@
} }
.basket-items { .basket-items {
margin-top: 20px;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
.certificate_type { .certificate_type {
...@@ -21,7 +26,7 @@ ...@@ -21,7 +26,7 @@
.row { .row {
padding-bottom: 30px; padding-bottom: 30px;
.course_name, .product-title,
.old-price, .old-price,
.price { .price {
margin: 0; margin: 0;
...@@ -30,11 +35,11 @@ ...@@ -30,11 +35,11 @@
font-weight: bold; font-weight: bold;
} }
.course_description { .product-description {
font-size: 14px; font-size: 14px;
} }
.course_prices { .product-prices {
@include text-align(right); @include text-align(right);
} }
...@@ -57,7 +62,7 @@ ...@@ -57,7 +62,7 @@
} }
} }
.course_image img { .product-image img {
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
...@@ -133,7 +138,7 @@ ...@@ -133,7 +138,7 @@
} }
} }
.course-price-label { .product-price-label {
font-weight: normal; font-weight: normal;
font-size: 0.84rem; font-size: 0.84rem;
+ span { + span {
...@@ -143,7 +148,7 @@ ...@@ -143,7 +148,7 @@
} }
} }
.total { .total, #summary {
padding-top: 20px; padding-top: 20px;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
...@@ -158,6 +163,7 @@ ...@@ -158,6 +163,7 @@
border: none; border: none;
background: none; background: none;
padding: 0; padding: 0;
font-size: 14px;
color: red; color: red;
} }
...@@ -228,12 +234,252 @@ ...@@ -228,12 +234,252 @@
} }
} }
.basket-client-side {
.close {
margin-top: -20px;
}
legend, p.title {
color: #337ab7;
font-size: 16px;
font-weight: bold;
}
#payment-method-information {
padding-top: 0;
}
.placeholder {
padding: 20px 0;
}
.help-block {
height: 10px;
color: red;
}
.payment-methods {
padding-left: 0;
}
#payment-method {
#payment-method-image, button {
float: left;
border: 1px solid palette(grayscale, base);
border-radius: 5px;
height: 50px;
}
#payment-method-image {
border-bottom: 4px solid #337ab7;
width: 150px;
line-height: 40px;
}
button {
position: relative;
left: -1px;
background-color: white;
width: 100px;
color: gray;
}
}
#payment-processor {
.payment-button {
border: 1px solid palette(grayscale, base);
border-radius: 5px;
background-position: center center;
background-repeat: no-repeat;
background-color: #fff;
width: 120px;
height: 50px;
&.payment-processor-paypal {
background-image: url('/static/images/paypal_logo.png');
}
}
}
#card-holder-information {
padding-top: 0;
}
.container {
#messages {
.alert-error {
color: #d2322d;
}
.alert-error {
background-color: #f2dede;
border-color: #ebccd1;
color: #a94442;
}
}
.payment-form {
.row {
margin: 0;
}
#payment-information {
border-left: 1px solid palette(grayscale, base);
legend {
padding-left: 15px;
}
.credit-card-list {
padding-left: 15px;
margin-top: -20px;
}
.form-input-elements {
div:not(.payment-button) {
margin: 0;
label {
color: gray;
font-weight: normal;
}
input, select {
border: 1px solid palette(grayscale, base);
border-radius: 0;
}
}
.asteriskField {
display: none;
}
#expiration-label {
width: 100%;
margin-left: 15px;
text-align: left;
}
#card-number {
height: 90px;
}
#payment-button {
margin-top: 20px;
}
.fa-lock {
position: relative;
float: left;
top: -25px;
left: 92%;
margin-right: -5%;
}
.card-type-icon {
position: relative;
float: left;
top: -30px;
left: 70%;
margin-right: -15%;
}
}
}
}
#summary {
padding-top: 0;
.row {
margin: 0;
}
fieldset > legend {
padding-top: 20px;
}
.description {
float: left;
}
.price {
text-align: right;
}
.row {
padding-bottom: 20px;
}
.amount {
padding-bottom: 20px;
color: palette(grayscale, base);
font-size: 18px;
.price {
font-weight: bold;
}
}
#basket-total {
font-size: 18px;
font-weight: bold;
}
#voucher-information {
.use-voucher {
padding: 0;
> button {
padding-left: 0;
border: 0;
background-color: white;
color: #337ab7;
}
}
.voucher {
float: left;
font-size: 14px;
margin: 0 10px 0 0;
line-height: 24px;
}
}
.product {
padding: 0;
.product-image {
img {
width: 100%;
max-width: 150px;
max-height: 150px;
}
}
.product-information {
.product-name {
margin-bottom: 0;
font-weight: bold;
}
#seat-type {
color: palette(grayscale, base);
}
}
}
.remove-voucher {
color: #337ab7;
}
}
}
}
/* Small devices (tablets, 992px and lower) */ /* Small devices (tablets, 992px and lower) */
@media (max-width: $screen-md-min - 1) { @media (max-width: $screen-md-min - 1) {
.basket { .basket {
.container { .container {
.basket-items { .basket-items {
.course-price-label { .product-price-label {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
margin: 0px 20px 20px 0px; margin: 0px 20px 20px 0px;
...@@ -259,8 +505,46 @@ ...@@ -259,8 +505,46 @@
display: inline-block; display: inline-block;
} }
.course_prices { .product-prices {
margin-top: 30px; margin-top: 30px;
} }
.total p.voucher {
margin: 0;
}
}
.basket-client-side .container {
padding: 20px 0 0 0;
.row {
margin-left: 0;
margin-right: 0;
}
.form-group {
display: block;
}
.payment-form #payment-information {
border-left: none;
}
.payment-methods {
padding-left: 0;
#payment-method {
padding-left: 0;
}
}
#summary #basket-information .product {
.product-image {
img {
max-width: 100%;
max-height: 100%;
}
}
}
} }
} }
{% extends 'edx/base.html' %} {% extends 'edx/base.html' %}
{% load core_extras %}
{% load i18n %} {% load i18n %}
{% load staticfiles %} {% load staticfiles %}
...@@ -13,13 +14,38 @@ ...@@ -13,13 +14,38 @@
{% block javascript %} {% block javascript %}
<script src="{% static 'js/apps/basket_app.js' %}"></script> <script src="{% static 'js/apps/basket_app.js' %}"></script>
<script src="{% static 'js/payment_processors/cybersource.js' %}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="basket"> <div class="basket {% if enable_client_side_checkout %}basket-client-side{% endif %}">
<div class="container"> <div class="container">
{# Use a partial template so that AJAX can be used to re-render basket #} {# Use a partial template so that AJAX can be used to re-render basket #}
{% include 'basket/partials/basket_content.html' %} {% if basket.is_empty %}
{% block emptybasket %}
<div class="depth depth-2 message-error-content">
<h3>{% trans "Your basket is empty" %}</h3>
{% captureas dashboard_link_start %}
<a href="{{ homepage_url }}">
{% endcaptureas %}
{% captureas support_link_start %}
<a href="{{ support_url }}">
{% endcaptureas %}
{% blocktrans with link_end="</a>" %}
If you have attempted to do a purchase, you have not been charged. Return to your {{ dashboard_link_start }}dashboard{{ link_end }} to try
again, or {{ support_link_start }}contact {{ platform_name }} Support{{ link_end }}.
{% endblocktrans %}
</div>
{% endblock %}
{% else %}
{% if enable_client_side_checkout %}
{% include 'basket/partials/client_side_checkout_basket.html' %}
{% else %}
{% include 'basket/partials/hosted_checkout_basket.html' %}
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
{% load i18n %}
<div id="voucher_form_container" class="voucher_form">
<form id="voucher_form" action="{% url 'basket:vouchers-add' %}" method="post">
{% csrf_token %}
<div class="code">
<input id="id_code" name="code" type="text"
placeholder="{% trans 'Enter a coupon code' %}">
<button type="submit"
class="btn btn-default"
data-loading-text="{% trans 'Applying...' %}"
data-track-type="click"
data-track-event="edx.bi.ecommerce.basket.voucher_applied"
data-track-category="voucher-application"
data-voucher-code="{{ voucher_code }}">
{% trans "Apply" %}
</button>
</div>
</form>
</div>
{% load i18n %}
{% load currency_filters %}
<div id="basket_totals" class="col-xs-3">
{% block order_total %}
{% trans "Total:" %}
{{ order_total.incl_tax|currency:basket.currency }}
{% endblock %}
</div>
{% load string_filters %}
{% comment %}
Use message tags to control these alerts. Available tags include:
- safe: allow HTML in the message
- block: for longer messages - this adds extra padding
- noicon: don't show an icon
- error/success/info - these change the connotation of the alert
{% endcomment %}
<div id="messages">
{% if messages %}
{% for message in messages %}
<div class="alert {% for tag in message.tags|split %}alert-{{ tag }} {% endfor %} fade in" aria-live="polite">
<div class="alertinner {% if 'noicon' not in message.tags %}wicon{% endif %}">
{# Allow HTML to be embedded in messages #}
{% if 'safe' in message.tags %}
{{ message|safe }}
{% else %}
{{ message }}
{% endif %}
{% if 'noicon' not in message.tags %}
{# Include an icon by default #}
{% if 'success' in message.tags %}
<i class="icon-ok-sign"></i>
{% elif 'info' in message.tags %}
<i class="icon-info-sign"></i>
{% elif 'warning' in message.tags %}
<i class="icon-warning-sign"></i>
{% elif 'danger' in message.tags %}
<i class="icon-exclamation-sign"></i>
{% endif %}
{% endif %}
</div>
<a class="close" data-dismiss="alert" href="#">&times;</a>
</div>
{% endfor %}
{% endif %}
</div>
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