Commit 92607fe2 by Vedran Karačić Committed by GitHub

Merge pull request #1048 from edx/vkaracic/new-basket

Client side checkout basket
parents fadca204 3d16cc96
......@@ -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):
"""Verify the correct seat type attribute is retrieved."""
course = CourseFactory()
......@@ -290,7 +303,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.context['is_bulk_purchase'])
self.assertFalse(response.context['show_voucher_form'])
# Enable enrollment codes
self.site.siteconfiguration.enable_enrollment_codes = True
......@@ -298,7 +311,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
response = self.client.get(self.path)
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]
self.assertEqual(line_data['seat_type'], _(enrollment_code.attr.seat_type.capitalize()))
......@@ -370,7 +383,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
line_data = response.context['formset_lines_data'][0][1]
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['course_name'], self.course.name)
self.assertEqual(line_data['product_title'], self.course.name)
self.assertFalse(line_data['enrollment_code'])
self.assertEqual(response.context['payment_processors'][0].NAME, DummyProcessor.NAME)
......@@ -446,7 +459,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
self.create_basket_and_add_product(seat)
response = self.client.get(self.path)
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):
""" Verify the variable for verification requirement is False when the attribute is missing. """
......@@ -455,7 +468,7 @@ class BasketSummaryViewTests(CourseCatalogTestMixin, CourseCatalogMockMixin, Lms
self.create_basket_and_add_product(seat)
response = self.client.get(self.path)
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)
def test_client_side_checkout(self):
......
......@@ -57,6 +57,8 @@ def prepare_basket(request, product, voucher=None):
def get_basket_switch_data(product):
product_class_name = product.get_product_class().name
structure = product.structure
switch_link_text = None
if product_class_name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME:
switch_link_text = _('Click here to just purchase an enrollment for yourself')
......
from __future__ import unicode_literals
from datetime import datetime
import logging
import waffle
......@@ -96,42 +97,71 @@ class BasketSummaryView(BasketView):
seat_type = get_certificate_type_display_value(product.attr.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):
context = super(BasketSummaryView, self).get_context_data(**kwargs)
formset = context.get('formset', [])
lines = context.get('line_list', [])
lines_data = []
is_verification_required = is_bulk_purchase = False
display_verification_message = False
show_voucher_form = True
switch_link_text = partner_sku = ''
basket = self.request.basket
site = self.request.site
site_configuration = site.siteconfiguration
for line in lines:
course_key = CourseKey.from_string(line.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)
product_class_name = line.product.get_product_class().name
if product_class_name == 'Seat':
line_data = self._get_course_data(line.product)
if (getattr(line.product.attr, 'id_verification_required', False) and
line.product.attr.certificate_type != 'credit'):
display_verification_message = True
elif product_class_name == 'Enrollment Code':
line_data = self._get_course_data(line.product)
show_voucher_form = False
else:
line_data = {
'product_title': line.product.title,
'image_url': None,
'product_description': line.product.description
}
# TODO: handle these links for multi-line baskets.
if self.request.site.siteconfiguration.enable_enrollment_codes:
# Get variables for the switch link that toggles from enrollment codes and seat.
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:
benefit = basket.applied_offers().values()[0].benefit
......@@ -139,62 +169,70 @@ class BasketSummaryView(BasketView):
else:
benefit_value = None
lines_data.append({
line_data.update({
'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,
'enrollment_code': line.product.get_product_class().name == ENROLLMENT_CODE_PRODUCT_CLASS_NAME,
'line': line,
})
lines_data.append(line_data)
user = self.request.user
context.update({
'analytics_data': prepare_analytics_data(
user,
self.request.site.siteconfiguration.segment_key,
unicode(course_key)
),
'enable_client_side_checkout': False,
})
course_key = lines_data[0].get('course_key') if len(lines) == 1 else None
user = self.request.user
context.update({
'analytics_data': prepare_analytics_data(
user,
self.request.site.siteconfiguration.segment_key,
unicode(course_key)
),
'enable_client_side_checkout': False,
})
if site_configuration.client_side_payment_processor \
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)
context.update({
'enable_client_side_checkout': True,
'payment_form': PaymentForm(user=user, initial={'basket': basket}, label_suffix=''),
'payment_url': payment_processor.client_side_payment_url,
})
else:
msg = 'Unable to load client-side payment processor [{processor}] for ' \
'site configuration [{sc}]'.format(processor=site_configuration.client_side_payment_processor,
sc=site_configuration.id)
raise SiteConfigurationError(msg)
# Check product attributes to determine if ID verification is required for this basket
try:
is_verification_required = line.product.attr.id_verification_required \
and line.product.attr.certificate_type != 'credit'
except AttributeError:
pass
payment_processors = site_configuration.get_payment_processors()
if site_configuration.client_side_payment_processor \
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)
today = datetime.today()
context.update({
'enable_client_side_checkout': True,
'client_side_payment_processor_name': payment_processor.NAME,
'paypal_enabled': 'paypal' in (p.NAME for p in payment_processors),
'months': range(1, 13),
'payment_form': PaymentForm(user=user, initial={'basket': basket}, label_suffix=''),
'payment_url': payment_processor.client_side_payment_url,
# Assumption is that the credit card duration is 15 years
'years': range(today.year, today.year + 16)
})
else:
msg = 'Unable to load client-side payment processor [{processor}] for ' \
'site configuration [{sc}]'.format(processor=site_configuration.client_side_payment_processor,
sc=site_configuration.id)
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({
'total_benefit': total_benefit,
'free_basket': context['order_total'].incl_tax == 0,
'payment_processors': site_configuration.get_payment_processors(),
'payment_processors': payment_processors,
'homepage_url': get_lms_url(''),
'formset_lines_data': zip(formset, lines_data),
'is_verification_required': is_verification_required,
'display_verification_message': display_verification_message,
'min_seat_quantity': 1,
'is_bulk_purchase': is_bulk_purchase,
'show_voucher_form': show_voucher_form,
'switch_link_text': switch_link_text,
'partner_sku': partner_sku,
'course_key': course_key
})
return context
......
......@@ -54,7 +54,6 @@ class FreeCheckoutViewTests(TestCase):
""" Verify redirect to the receipt page. """
self.prepare_basket(0)
self.assertEqual(Order.objects.count(), 0)
response = self.client.get(self.path)
self.assertEqual(Order.objects.count(), 1)
......@@ -71,7 +70,7 @@ class FreeCheckoutViewTests(TestCase):
self.toggle_ecommerce_receipt_page(False)
self.prepare_basket(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)
self.assertEqual(Order.objects.count(), 1)
......
......@@ -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'))
params = urllib.urlencode({'order_number': order_number}) if order_number 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 ''
return '{base_url}{params}'.format(
......
......@@ -8,13 +8,18 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
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__)
Basket = get_model('basket', 'Basket')
def country_choices():
""" 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):
......@@ -27,13 +32,66 @@ class PaymentForm(forms.Form):
def __init__(self, user, *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)
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())
first_name = forms.CharField(max_length=60, label=_('First Name'))
last_name = forms.CharField(max_length=60, label=_('Last Name'))
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'))
state = forms.CharField(max_length=60, required=False, label=_('State/Province'))
postal_code = forms.CharField(max_length=10, required=False, label=_('Zip/Postal Code'))
......
......@@ -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.
"""
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
@mock.patch('ecommerce.extensions.payment.processors.paypal.paypalrestsdk.Payment')
......
......@@ -180,7 +180,7 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket,
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)
self.assertRedirects(response, self.get_full_url(reverse('payment_error')))
......@@ -201,9 +201,14 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket,
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)
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):
""" Verify the view redirects to the Receipt page when the Order has been successfully placed. """
......@@ -224,6 +229,6 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
self.basket,
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)
self.assertRedirects(response, self.get_full_url(path=reverse('payment_error')), status_code=302)
......@@ -6,61 +6,8 @@ require([
BasketPage) {
'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 () {
BasketPage.onReady();
initializePaymentForm();
});
}
);
......@@ -7,12 +7,14 @@ define([
'underscore',
'underscore.string',
'utils/utils',
'utils/credit_card',
'js-cookie'
],
function ($,
_,
_s,
Utils,
CreditCardUtils,
Cookies
) {
'use strict';
......@@ -37,7 +39,6 @@ define([
success: onSuccess,
error: onFail
});
},
hideVoucherForm = function() {
$('#voucher_form_container').hide();
......@@ -67,11 +68,74 @@ define([
$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() {
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();
showVoucherForm();
});
......@@ -81,6 +145,33 @@ define([
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) {
var $btn = $(e.target),
deferred = new $.Deferred(),
......@@ -127,11 +218,12 @@ define([
return {
appendToForm: appendToForm,
cardInfoValidation: cardInfoValidation,
checkoutPayment: checkoutPayment,
hideVoucherForm: hideVoucherForm,
onSuccess: onSuccess,
onFail: onFail,
onReady: onReady,
onSuccess: onSuccess,
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 {
.close {
margin-top: -20px;
}
.container {
padding: 60px 20px 30px 20px;
......@@ -12,6 +16,7 @@
}
.basket-items {
margin-top: 20px;
border-bottom: 1px solid #ddd;
.certificate_type {
......@@ -21,7 +26,7 @@
.row {
padding-bottom: 30px;
.course_name,
.product-title,
.old-price,
.price {
margin: 0;
......@@ -30,11 +35,11 @@
font-weight: bold;
}
.course_description {
.product-description {
font-size: 14px;
}
.course_prices {
.product-prices {
@include text-align(right);
}
......@@ -57,7 +62,7 @@
}
}
.course_image img {
.product-image img {
margin: 0 auto;
width: 100%;
}
......@@ -133,7 +138,7 @@
}
}
.course-price-label {
.product-price-label {
font-weight: normal;
font-size: 0.84rem;
+ span {
......@@ -143,7 +148,7 @@
}
}
.total {
.total, #summary {
padding-top: 20px;
margin-bottom: 1.25rem;
......@@ -158,6 +163,7 @@
border: none;
background: none;
padding: 0;
font-size: 14px;
color: red;
}
......@@ -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) */
@media (max-width: $screen-md-min - 1) {
.basket {
.container {
.basket-items {
.course-price-label {
.product-price-label {
font-size: 20px;
font-weight: bold;
margin: 0px 20px 20px 0px;
......@@ -259,8 +505,46 @@
display: inline-block;
}
.course_prices {
.product-prices {
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' %}
{% load core_extras %}
{% load i18n %}
{% load staticfiles %}
......@@ -13,13 +14,38 @@
{% block javascript %}
<script src="{% static 'js/apps/basket_app.js' %}"></script>
<script src="{% static 'js/payment_processors/cybersource.js' %}"></script>
{% endblock %}
{% block content %}
<div class="basket">
<div class="basket {% if enable_client_side_checkout %}basket-client-side{% endif %}">
<div class="container">
{# 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>
{% 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