diff --git a/build.js b/build.js index 0ca068f75b6..98a8e55a7dc 100644 --- a/build.js +++ b/build.js @@ -63,6 +63,10 @@ { name: 'js/views/cowpay', exclude: ['js/common'] + }, + { + name: 'js/views/authorizenet', + exclude: ['js/common'] } ] }) diff --git a/ecommerce/extensions/payment/forms.py b/ecommerce/extensions/payment/forms.py index 60d543666d0..39f293fee11 100644 --- a/ecommerce/extensions/payment/forms.py +++ b/ecommerce/extensions/payment/forms.py @@ -318,3 +318,111 @@ def clean_basket(self): Applicator().apply(basket, self.request.user, self.request) return basket + + +class AuthorizenetPaymentForm(forms.Form): + """ + Payment form for Authorizenet with billing details. + + This form captures the data necessary to complete a payment transaction with Authorizenet. + """ + def __init__(self, user, request, *args, **kwargs): + super(AuthorizenetPaymentForm, self).__init__(*args, **kwargs) + self.request = request + self.basket_has_enrollment_code_product = any( + line.product.is_enrollment_code_product for line in self.request.basket.all_lines() + ) + update_basket_queryset_filter(self, user) + + self.helper = FormHelper(self) + self.helper.layout = Layout( + Div('basket'), + Div( + Div('full_name'), + HTML('

'), + css_class='form-item col-md-12' + ), + Div( + Div('card_number'), + HTML('

'), + css_class='form-item col-md-12' + ), + Div( + Div('card_code', css_class='form-item col-md-4'), + Div('expiry_month', css_class='form-item col-md-4'), + Div('expiry_year', css_class='form-item col-md-4'), + HTML('

'), + css_class='row' + ), + Div( + HTML(''), + css_class='form-item col-md-12' + ), + Div( + HTML(''), + HTML('
'), + css_class='form-item col-md-12' + ), + ) + + for bound_field in list(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' + + if self.basket_has_enrollment_code_product and 'organization' not in self.fields: + # If basket has any enrollment code items then we will add an organization + # field next to "last_name." + self.fields['organization'] = forms.CharField(max_length=60, label=_('Organization (required)')) + organization_div = Div( + Div( + Div('organization'), + HTML('

'), + css_class='form-item col-md-6' + ), + css_class='row' + ) + self.helper.layout.fields.insert(list(self.fields.keys()).index('last_name') + 1, organization_div) + # Purchased on behalf of an enterprise or for personal use + self.fields[PURCHASER_BEHALF_ATTRIBUTE] = forms.BooleanField( + required=False, + label=_('I am purchasing on behalf of my employer or other professional organization') + ) + purchaser_div = Div( + Div( + Div(PURCHASER_BEHALF_ATTRIBUTE), + HTML('

'), + css_class='form-item col-md-12' + ), + css_class='row' + ) + self.helper.layout.fields.insert(list(self.fields.keys()).index('organization') + 1, purchaser_div) + + basket = forms.ModelChoiceField( + queryset=Basket.objects.all(), + widget=forms.HiddenInput(), + required=False, + error_messages={ + 'invalid_choice': _('There was a problem retrieving your basket. Refresh the page to try again.'), + } + ) + full_name = forms.CharField(max_length=60, label=_('Full Name')) + card_number = forms.CharField(max_length=16, required=False, label=_('Card Number')) + card_code = forms.CharField(max_length=4, required=False, label=_('CVV')) + expiry_month = forms.CharField(max_length=60, required=False, label=_('Expiry Month (mm)')) + expiry_year = forms.CharField(max_length=60, required=False, label=_('Expiry Year (yy)')) + data_descriptor = forms.CharField(max_length=255) + data_value = forms.CharField(max_length=255) + + def clean_basket(self): + basket = self.cleaned_data['basket'] + + if basket: + basket.strategy = self.request.strategy + Applicator().apply(basket, self.request.user, self.request) + + return basket diff --git a/ecommerce/extensions/payment/processors/authorizenet.py b/ecommerce/extensions/payment/processors/authorizenet.py index 5ca67a6fe8b..bbe2caaa973 100644 --- a/ecommerce/extensions/payment/processors/authorizenet.py +++ b/ecommerce/extensions/payment/processors/authorizenet.py @@ -1,4 +1,7 @@ """ AuthorizeNet payment processor. """ +from __future__ import absolute_import, unicode_literals + +import crum import json import logging from urllib.parse import quote @@ -11,8 +14,8 @@ ) from authorizenet.constants import constants from django.urls import reverse -from oscar.apps.payment.exceptions import GatewayError -from oscar.core.loading import get_model +from oscar.apps.payment.exceptions import GatewayError, TransactionDeclined +from oscar.core.loading import get_class, get_model from ecommerce.core.url_utils import get_ecommerce_url from ecommerce.extensions.payment.exceptions import ( @@ -21,10 +24,12 @@ PaymentProcessorResponseNotFound, RefundError ) +from ecommerce.extensions.payment.forms import AuthorizenetPaymentForm from ecommerce.extensions.payment.processors import BaseClientSidePaymentProcessor, HandledProcessorResponse from ecommerce.extensions.payment.utils import LxmlObjectJsonEncoder logger = logging.getLogger(__name__) +Applicator = get_class('offer.applicator', 'Applicator') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') AUTH_CAPTURE_TRANSACTION_TYPE = "authCaptureTransaction" @@ -351,3 +356,165 @@ def issue_credit(self, order_number, basket, reference_number, amount, currency) order_number) logger.exception(msg) raise RefundError(msg) + + +class AuthorizenetClient(BaseClientSidePaymentProcessor): + NAME = 'authorizenetclient' + template_name = 'payment/authorizenet.html' + + def __init__(self, site): + """ + Constructs a new instance of the Authorizenet Client processor. + + Raises: + KeyError: If no settings configured for this payment processor. + """ + super(AuthorizenetClient, self).__init__(site) + self.request = crum.get_current_request() + configuration = self.configuration + self.client_key = configuration['client_key'] + self.base_url = configuration['base_url'] + self.api_login_id = configuration['api_login_id'] + self.transaction_key = configuration['transaction_key'] + self.authorizenet_production_mode = configuration['production_mode'] + + @property + def authorizenet_form(self): + return AuthorizenetPaymentForm( + user=self.request.user, + request=self.request, + initial={'basket': self.request.basket}, + label_suffix='' + ) + + def get_transaction_parameters(self, basket, request=None, **kwargs): + basket.strategy = self.request.strategy + Applicator().apply(basket, self.request.user, self.request) + + merchant_auth = apicontractsv1.merchantAuthenticationType() + merchant_auth.name = self.api_login_id + merchant_auth.transactionKey = self.transaction_key + + # Create the payment object for a payment nonce + opaque_data = apicontractsv1.opaqueDataType() + opaque_data.dataDescriptor = kwargs['data_descriptor'] + opaque_data.dataValue = kwargs['data_value'] + + # Add the payment data to a paymentType object + payment_one = apicontractsv1.paymentType() + payment_one.opaqueData = opaque_data + + # Create order information + order = apicontractsv1.orderType() + order.invoiceNumber = basket.order_number + order.description = '{} - {}: {}'.format( + basket.order_number, + basket.site.partner.name, + basket.all_lines().first().product.title + ) + + # Set the customer's Bill To address + owner = basket.owner + customer_address = apicontractsv1.customerAddressType() + customer_address.firstName = owner.first_name + customer_address.lastName = owner.last_name + + # Set the customer's identifying information + customer_data = apicontractsv1.customerDataType() + customer_data.type = "individual" + customer_data.id = str(owner.id) + customer_data.email = owner.email + + # Add values for transaction settings + duplicate_window_setting = apicontractsv1.settingType() + duplicate_window_setting.settingName = "duplicateWindow" + duplicate_window_setting.settingValue = "600" + settings = apicontractsv1.ArrayOfSetting() + settings.setting.append(duplicate_window_setting) + + # Create a transactionRequestType object and add the previous objects to it + transaction_request = apicontractsv1.transactionRequestType() + transaction_request.transactionType = "authCaptureTransaction" + transaction_request.amount = basket.total_incl_tax + transaction_request.order = order + transaction_request.payment = payment_one + transaction_request.billTo = customer_address + transaction_request.customer = customer_data + transaction_request.transactionSettings = settings + + # Assemble the complete transaction request + create_transaction_request = apicontractsv1.createTransactionRequest() + create_transaction_request.merchantAuthentication = merchant_auth + create_transaction_request.refId = basket.order_number + create_transaction_request.transactionRequest = transaction_request + + # Create the controller and get response + create_transaction_controller = createTransactionController(create_transaction_request) + if self.authorizenet_production_mode: + create_transaction_controller.setenvironment(constants.PRODUCTION) + + create_transaction_controller.execute() + + response = create_transaction_controller.getresponse() + + if response is not None: + if response.messages.resultCode == 'Ok': + if hasattr(response.transactionResponse, 'messages'): + logger.info('Successfully created transaction with Transaction ID: %s' % response.transactionResponse.transId) + logger.info('Transaction Response Code: %s' % response.transactionResponse.responseCode) + logger.info('Message Code: %s' % response.transactionResponse.messages.message[0].code) + logger.info('Auth Code: %s' % response.transactionResponse.authCode) + logger.info('Description: %s' % response.transactionResponse.messages.message[0].description) + else: + if hasattr(response.transactionResponse, 'errors'): + logger.info('Error Code: %s' % str(response.transactionResponse.errors.error[0].errorCode)) + logger.info('Error Message: %s' % response.transactionResponse.errors.error[0].errorText) + self.handle_processor_response(response, basket) + raise TransactionDeclined( + 'Payment error: {}'.format( + response.transactionResponse.errors.error[0].errorText + ) + ) + else: + if hasattr(response, 'transactionResponse') and hasattr(response.transactionResponse, 'errors'): + logger.info('Error Code: %s' % str(response.transactionResponse.errors.error[0].errorCode)) + logger.info('Error Message: %s' % response.transactionResponse.errors.error[0].errorText) + self.handle_processor_response(response, basket) + raise TransactionDeclined( + 'Payment error: {}'.format( + response.transactionResponse.errors.error[0].errorText + ) + ) + else: + logger.info('Error Code: %s' % response.messages.message[0]['code'].text) + logger.info('Error Message: %s' % response.messages.message[0]['text'].text) + self.handle_processor_response(response, basket) + raise TransactionDeclined( + 'Payment Gateway error: {}'.format( + response.messages.message[0]['text'].text + ) + ) + + return response + + def handle_processor_response(self, response, basket=None): + currency = basket.currency + transaction_id = response.transactionResponse.transId if hasattr(response.transactionResponse, 'transId') else None + transaction_dict = LxmlObjectJsonEncoder().encode(response) + + self.record_processor_response(transaction_dict, transaction_id=transaction_id, basket=basket) + logger.info('Successfully created Authorizenet charge [%s] for basket [%d].', 'merchant_id', basket.id) + + total = basket.total_incl_tax + card_type = self.NAME + + return HandledProcessorResponse( + transaction_id=transaction_id, + total=total, + currency=currency, + card_number='XXXX', + card_type=card_type + ) + + def issue_credit(self, order_number, basket, reference_number, amount, currency): + raise NotImplementedError('Authorizenet payment processor does not support refunds.') diff --git a/ecommerce/extensions/payment/processors/cowpay.py b/ecommerce/extensions/payment/processors/cowpay.py index 9ace0f72096..32757903f80 100644 --- a/ecommerce/extensions/payment/processors/cowpay.py +++ b/ecommerce/extensions/payment/processors/cowpay.py @@ -84,7 +84,7 @@ def fawry_form(self): @property def iframe_token(self): - cowpay_url = urljoin(self.base_url, '/api/v1/iframe/token') + cowpay_url = urljoin(self.base_url, '/api/v2/charge/card/init') headers = self._get_request_header() basket = self.request.basket payload = self._get_request_payload( @@ -104,7 +104,7 @@ def receipt_page_url(self): return get_receipt_page_url(site.siteconfiguration, order_number=basket.order_number) def get_transaction_parameters(self, basket, request=None, **kwargs): - cowpay_url = urljoin(self.base_url, '/api/v1/charge/fawry') + cowpay_url = urljoin(self.base_url, '/api/v2/charge/fawry') headers = self._get_request_header() data = kwargs.get('form_data') if data: diff --git a/ecommerce/extensions/payment/urls.py b/ecommerce/extensions/payment/urls.py index 347416348d4..a1e0d0e6db3 100644 --- a/ecommerce/extensions/payment/urls.py +++ b/ecommerce/extensions/payment/urls.py @@ -31,6 +31,7 @@ AUTHORIZENET_URLS = [ url(r'^notification/$', authorizenet.AuthorizeNetNotificationView.as_view(), name='authorizenet_notifications'), url(r'^redirect/$', authorizenet.handle_redirection, name='redirect'), + url(r'^submit/$', authorizenet.AuthorizenetClientView.as_view(), name='submit'), ] COWPAY_URLS = [ diff --git a/ecommerce/extensions/payment/views/authorizenet.py b/ecommerce/extensions/payment/views/authorizenet.py index 9efb02112b3..5c3adb0dbe7 100644 --- a/ecommerce/extensions/payment/views/authorizenet.py +++ b/ecommerce/extensions/payment/views/authorizenet.py @@ -6,20 +6,27 @@ import logging from django.conf import settings +from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import redirect +from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from oscar.apps.partner import strategy +from oscar.apps.payment.exceptions import TransactionDeclined from oscar.core.loading import get_class, get_model from rest_framework.views import APIView +from ecommerce.extensions.basket.utils import basket_add_organization_attribute from ecommerce.core.url_utils import get_lms_dashboard_url from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin +from ecommerce.extensions.checkout.utils import get_receipt_page_url from ecommerce.extensions.payment.exceptions import InvalidBasketError -from ecommerce.extensions.payment.processors.authorizenet import AuthorizeNet +from ecommerce.extensions.payment.forms import AuthorizenetPaymentForm +from ecommerce.extensions.payment.processors.authorizenet import AuthorizeNet, AuthorizenetClient +from ecommerce.extensions.payment.views import BasePaymentSubmitView from ecommerce.notifications.notifications import send_notification logger = logging.getLogger(__name__) @@ -252,3 +259,61 @@ def handle_redirection(request): response.set_cookie('pendingTransactionCourse', course_id_hash, domain=domain) return response + + +class AuthorizenetClientView(EdxOrderPlacementMixin, BasePaymentSubmitView): + + + form_class = AuthorizenetPaymentForm + + @property + def payment_processor(self): + return AuthorizenetClient(self.request.site) + + def post(self, request): # pylint: disable=unused-argument + form_kwargs = self.get_form_kwargs() + form = self.form_class(**form_kwargs) + + try: + if form.is_valid(): + return self.form_valid(form) + except (TransactionDeclined) as exp: + messages.add_message(request, messages.ERROR, 'Payment error: {}'.format(str(exp))) + basket_url = reverse('basket:summary') + return HttpResponseRedirect(basket_url) + + return self.form_invalid(form) + + def form_valid(self, form): + form_data = form.cleaned_data + basket = form_data['basket'] + order_number = basket.order_number + data_descriptor = form_data['data_descriptor'] + data_value = form_data['data_value'] + + basket_add_organization_attribute(basket, self.request.POST) + + response = self.payment_processor.get_transaction_parameters( + basket, data_descriptor=data_descriptor, data_value=data_value + ) + try: + self.handle_payment(response, basket) + except Exception: # pylint: disable=broad-except + logger.exception('An error occurred while processing the Authorizent payment for basket [%d].', basket.id) + return JsonResponse({}, status=400) + + try: + order = self.create_order(self.request, basket) + except Exception: # pylint: disable=broad-except + logger.exception('An error occurred while processing the Authorizenet payment for basket [%d].', basket.id) + return JsonResponse({}, status=400) + + self.handle_post_order(order) + + receipt_url = get_receipt_page_url( + site_configuration=self.request.site.siteconfiguration, + order_number=order_number, + disable_back_button=True, + ) + + return HttpResponseRedirect(receipt_url) diff --git a/ecommerce/extensions/payment/views/cowpay.py b/ecommerce/extensions/payment/views/cowpay.py index 87a11221697..9e57753d05e 100644 --- a/ecommerce/extensions/payment/views/cowpay.py +++ b/ecommerce/extensions/payment/views/cowpay.py @@ -178,7 +178,7 @@ def post(self, request): data['user'] = user.id logger.info('Data received: %s', data) - if not data['order_status'] == 'PAID': + if not data['payment_status'] == 'PAID': logger.warning('No execution step can be carried out until order status is PAID') return JsonResponse({'message': 'Payment has been cancelled.'}, status=200) diff --git a/ecommerce/settings/_oscar.py b/ecommerce/settings/_oscar.py index 2b123fc6246..03408d7ab5c 100644 --- a/ecommerce/settings/_oscar.py +++ b/ecommerce/settings/_oscar.py @@ -134,6 +134,7 @@ 'ecommerce.extensions.payment.processors.paypal.Paypal', 'ecommerce.extensions.payment.processors.stripe.Stripe', 'ecommerce.extensions.payment.processors.authorizenet.AuthorizeNet', + 'ecommerce.extensions.payment.processors.authorizenet.AuthorizenetClient', 'ecommerce.extensions.payment.processors.cowpay.Cowpay', ) diff --git a/ecommerce/static/js/payment_processors/authorizenet.js b/ecommerce/static/js/payment_processors/authorizenet.js new file mode 100644 index 00000000000..d2a369073bc --- /dev/null +++ b/ecommerce/static/js/payment_processors/authorizenet.js @@ -0,0 +1,91 @@ +/** + * Authorizenet payment processor specific actions. + */ +define([ + 'jquery', + 'utils/credit_card' +], function($, CreditCardUtils) { + 'use strict'; + + return { + init: function(config) { + let $paymentForm = $('#paymentForm'), + $paymentButton = $('#payment-button-authorizenet'), + $cardNumber = $('#id_card_number'), + $expiryMonth = $('#id_expiry_month'), + $expiryYear = $('#id_expiry_year'), + $cardCode = $('#id_card_code'), + $fullName = $('#id_full_name'); + + this.postURL = config.postURL; + $paymentForm.attr('action', config.postURL); + + $fullName.attr('required', true); + $cardNumber.attr('required', true); + $cardCode.attr('required', true); + $expiryMonth.attr('required', true); + $expiryYear.attr('required', true); + $expiryMonth.attr('maxLength', 2); + $expiryYear.attr('maxLength', 2); + + $cardNumber.keyup(function () { + $('.help-block-card').html(''); + if ($cardNumber.val() !== '' && !CreditCardUtils.isValidCardNumber($cardNumber.val())) { + $('.help-block-card').css('color', 'red').html('*Invalid Card Number'); + } + }); + + $paymentButton.on('click', function(e) { + e.preventDefault(); + $('.authorizenet-error').html(''); + + let isFormValid = $paymentForm.get(0).checkValidity(); + if (!isFormValid) { + $paymentForm.get(0).reportValidity(); + return; + } + + var authData = {}; + authData.clientKey = config.clientKey; + authData.apiLoginID = config.apiLoginID; + + var cardData = {}; + cardData.cardNumber = $cardNumber.val(); + cardData.month = $expiryMonth.val(); + cardData.year = $expiryYear.val(); + cardData.fullName = $fullName.val(); + cardData.cardCode = $cardCode.val(); + + var secureData = {}; + secureData.authData = authData; + secureData.cardData = cardData; + + Accept.dispatchData(secureData, responseHandler); + + function responseHandler(response) { + if (response.messages.resultCode === 'Error') { + var i = 0; + while (i < response.messages.message.length) { + $('.authorizenet-error').css('color', 'red').append("

*" + response.messages.message[i].text + "

"); + i = i + 1; + } + } else { + paymentFormUpdate(response.opaqueData); + } + } + + function paymentFormUpdate(opaqueData) { + $('#id_data_descriptor').val(opaqueData.dataDescriptor); + $('#id_data_value').val(opaqueData.dataValue); + + $cardNumber.val(''); + $cardCode.val(''); + $expiryYear.val(''); + $expiryMonth.val(''); + + $paymentForm.submit(); + } + }); + } + }; +}); diff --git a/ecommerce/static/js/payment_processors/cowpay.js b/ecommerce/static/js/payment_processors/cowpay.js index 55efd91e70a..9673234aa48 100644 --- a/ecommerce/static/js/payment_processors/cowpay.js +++ b/ecommerce/static/js/payment_processors/cowpay.js @@ -43,9 +43,9 @@ define([ } }; $cowpayButton.on('click', function() { - $('body').append($('
')); - COWPAYIFRAMEDIALOG.init(); - COWPAYIFRAMEDIALOG.load(config.cowpayIframeToken); + COWPAYOTPDIALOG.init(); + COWPAYOTPDIALOG.load(config.cowpayIframeToken); + $('#cowpay-otp-container').css({"position":"relative","z-index":"100"}); }) } }; diff --git a/ecommerce/static/js/views/authorizenet.js b/ecommerce/static/js/views/authorizenet.js new file mode 100644 index 00000000000..b52a5993cdc --- /dev/null +++ b/ecommerce/static/js/views/authorizenet.js @@ -0,0 +1,11 @@ +/* istanbul ignore next */ +require([ + 'jquery', + 'payment_processors/authorizenet' +], function($, AuthorizenetClient) { + 'use strict'; + + $(document).ready(function() { + AuthorizenetClient.init(window.AuthorizenetConfig); + }); +}); diff --git a/ecommerce/templates/payment/authorizenetclient.html b/ecommerce/templates/payment/authorizenetclient.html new file mode 100644 index 00000000000..56d72b596ce --- /dev/null +++ b/ecommerce/templates/payment/authorizenetclient.html @@ -0,0 +1,14 @@ +{% load static %} + + + + diff --git a/ecommerce/templates/payment/cowpay.html b/ecommerce/templates/payment/cowpay.html index 2f666541d9c..f6797d982ed 100644 --- a/ecommerce/templates/payment/cowpay.html +++ b/ecommerce/templates/payment/cowpay.html @@ -1,7 +1,6 @@ {% load static %} - - +