Commit 06dcd656 by Clinton Blackburn

Added support for Apple Pay

Learners with compatible browsers and hardware now have the option to pay with Apple Pay via CyberSource.

LEARNER-1811
parent 1d69b31d
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
"globals": { "globals": {
"gettext": true, "gettext": true,
"loadFixtures": true, "loadFixtures": true,
"spyOnEvent": true "spyOnEvent": true,
"ApplePaySession": true
} }
} }
...@@ -92,3 +92,7 @@ docs/_build ...@@ -92,3 +92,7 @@ docs/_build
# Visual Studio Code # Visual Studio Code
.vscode .vscode
# Certificates
*.cer
*.pem
...@@ -47,6 +47,10 @@ ...@@ -47,6 +47,10 @@
{ {
name: 'js/pages/program_offer_form_page', name: 'js/pages/program_offer_form_page',
exclude: ['js/common'] exclude: ['js/common']
},
{
name: 'js/views/cybersource_client_side_checkout',
exclude: ['js/common']
} }
] ]
}) })
Apple Pay
#########
Apple Pay support is available when using the CyberSource processor. Apple Pay allows learners to checkout quickly
without having to manually fill out the payment form. If you are not familiar with Apple Pay, please
take a moment to read the following documents to understand the user flow and necessary configuration.
* `Apple Pay JS <https://developer.apple.com/documentation/applepayjs>`_
* `CyberSource Simple Order API <https://www.cybersource.com/developers/integration_methods/apple_pay/>`_
Apple Pay is only available to learners using Safari on the following platforms:
* iOS 10+ on devices with a Secure Element
* macOS 10.12+. The user must have an iPhone, Apple Watch, or a MacBook Pro with Touch ID that can authorize the
payment.
An exhaustive list of devices that support Apple Pay is available on
`Wikipedia <https://en.wikipedia.org/wiki/Apple_Pay>`_.
.. note::
The Apple Pay button is not displayed to users with incompatible hardware and software.
Settings
--------
Apple Pay is configured via the ``PAYMENT_PROCESSOR_CONFIG`` dictionary in settings. The following keys are required.
.. list-table::
:header-rows: 1
* - Name
- Purpose
* - apple_pay_merchant_identifier
- Merchant identifier created at the `Apple Developer portal`_
* - apple_pay_country_code
- Two-letter `ISO 3166 country code <https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2>`_ for your
business/merchant account
* - apple_pay_merchant_id_domain_association
- Domain verification text obtained from the `Apple Developer portal`_
* - apple_pay_merchant_id_certificate_path
- Filesystem path to the merchant identity certificate (used to authenticate with Apple to start sessions). This
file should be kept in a secure location that is only accessible by administrators and the application'
service user.
.. _Apple Developer portal: https://developer.apple.com/account/ios/identifier/merchant
.. _Changing Payment Processors: .. _Changing Payment Processors:
#############################
Changing Payment Processors Changing Payment Processors
############################# ###########################
Payment processors sometimes experience temporary outages. When these outages Payment processors sometimes experience temporary outages. When these outages
......
...@@ -14,6 +14,7 @@ data if one of your usual processors is unavailable. ...@@ -14,6 +14,7 @@ data if one of your usual processors is unavailable.
send_notifications send_notifications
change_processors change_processors
apple_pay
track_data track_data
gate_ecommerce gate_ecommerce
maintain_ecommerce maintain_ecommerce
......
...@@ -8,7 +8,7 @@ msgid "" ...@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-07-05 12:09-0400\n" "POT-Creation-Date: 2017-07-16 17:22-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -62,41 +62,41 @@ msgstr "" ...@@ -62,41 +62,41 @@ msgstr ""
msgid "Must occur before end date" msgid "Must occur before end date"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:56 #: ecommerce/static/js/models/course_model.js:54
msgid "You must select a course type." msgid "You must select a course type."
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:62 #: ecommerce/static/js/models/course_model.js:60
msgid "You must choose if an honor seat should be created." msgid "You must choose if an honor seat should be created."
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:79 #: ecommerce/static/js/models/course_model.js:77
msgid "The verification deadline must occur AFTER the upgrade deadline." msgid "The verification deadline must occur AFTER the upgrade deadline."
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:88 #: ecommerce/static/js/models/course_model.js:86
msgid "Product validation failed." msgid "Product validation failed."
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:96 #: ecommerce/static/js/models/course_model.js:94
#: ecommerce/static/js/views/dynamic_catalog_view.js:70 #: ecommerce/static/js/views/dynamic_catalog_view.js:70
msgid "Course ID" msgid "Course ID"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:97 #: ecommerce/static/js/models/course_model.js:95
msgid "Course Name" msgid "Course Name"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:98 #: ecommerce/static/js/models/course_model.js:96
#: ecommerce/static/js/views/course_list_view.js:72 #: ecommerce/static/js/views/course_list_view.js:72
msgid "Course Type" msgid "Course Type"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:99 #: ecommerce/static/js/models/course_model.js:97
msgid "Verification Deadline" msgid "Verification Deadline"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_model.js:100 #: ecommerce/static/js/models/course_model.js:98
msgid "Include Honor Seat" msgid "Include Honor Seat"
msgstr "" msgstr ""
...@@ -113,12 +113,12 @@ msgid "The upgrade deadline must occur BEFORE the verification deadline." ...@@ -113,12 +113,12 @@ msgid "The upgrade deadline must occur BEFORE the verification deadline."
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_seats/course_seat.js:84 #: ecommerce/static/js/models/course_seats/course_seat.js:84
#: ecommerce/static/js/views/course_form_view.js:63 #: ecommerce/static/js/views/course_form_view.js:60
msgid "Verified" msgid "Verified"
msgstr "" msgstr ""
#: ecommerce/static/js/models/course_seats/course_seat.js:86 #: ecommerce/static/js/models/course_seats/course_seat.js:86
#: ecommerce/static/js/views/course_form_view.js:74 #: ecommerce/static/js/views/course_form_view.js:71
msgid "Credit" msgid "Credit"
msgstr "" msgstr ""
...@@ -244,38 +244,50 @@ msgid "" ...@@ -244,38 +244,50 @@ msgid ""
"again." "again."
msgstr "" msgstr ""
#. Translators: Do not translate "Apple Pay".
#: ecommerce/static/js/payment_processors/cybersource.js:211
msgid ""
"Apple Pay is not available at this time. Please try another payment method."
msgstr ""
#: ecommerce/static/js/payment_processors/cybersource.js:242
msgid ""
"An error occurred while processing your payment. You have NOT been charged. "
"Please try again, or select another payment method."
msgstr ""
#: ecommerce/static/js/utils/utils.js:184 #: ecommerce/static/js/utils/utils.js:184
msgid "Trailing comma not allowed." msgid "Trailing comma not allowed."
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_detail_view.js:107 #: ecommerce/static/js/views/coupon_detail_view.js:107
#: ecommerce/static/js/views/coupon_form_view.js:61 #: ecommerce/static/js/views/coupon_form_view.js:60
msgid "Can be used once by one customer" msgid "Can be used once by one customer"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_detail_view.js:109 #: ecommerce/static/js/views/coupon_detail_view.js:109
#: ecommerce/static/js/views/coupon_form_view.js:69 #: ecommerce/static/js/views/coupon_form_view.js:68
msgid "Can be used multiple times by multiple customers" msgid "Can be used multiple times by multiple customers"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_detail_view.js:111 #: ecommerce/static/js/views/coupon_detail_view.js:111
#: ecommerce/static/js/views/coupon_form_view.js:65 #: ecommerce/static/js/views/coupon_form_view.js:64
msgid "Can be used once by multiple customers" msgid "Can be used once by multiple customers"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_form_view.js:50 #: ecommerce/static/js/views/coupon_form_view.js:49
msgid "Enrollment Code" msgid "Enrollment Code"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_form_view.js:54 #: ecommerce/static/js/views/coupon_form_view.js:53
msgid "Discount Code" msgid "Discount Code"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_form_view.js:769 #: ecommerce/static/js/views/coupon_form_view.js:768
msgid "Save Changes" msgid "Save Changes"
msgstr "" msgstr ""
#: ecommerce/static/js/views/coupon_form_view.js:784 #: ecommerce/static/js/views/coupon_form_view.js:783
msgid "Create Coupon" msgid "Create Coupon"
msgstr "" msgstr ""
...@@ -333,30 +345,30 @@ msgstr "" ...@@ -333,30 +345,30 @@ msgstr ""
msgid "Coupon Report" msgid "Coupon Report"
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:58 #: ecommerce/static/js/views/course_form_view.js:55
msgid "Free (Audit)" msgid "Free (Audit)"
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:59 #: ecommerce/static/js/views/course_form_view.js:56
msgid "Free audit track. No certificate." msgid "Free audit track. No certificate."
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:64 #: ecommerce/static/js/views/course_form_view.js:61
msgid "" msgid ""
"Paid certificate track with initial verification and Verified Certificate" "Paid certificate track with initial verification and Verified Certificate"
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:68 #: ecommerce/static/js/views/course_form_view.js:65
msgid "Professional Education" msgid "Professional Education"
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:69 #: ecommerce/static/js/views/course_form_view.js:66
msgid "" msgid ""
"Paid certificate track with initial verification and Professional Education " "Paid certificate track with initial verification and Professional Education "
"Certificate" "Certificate"
msgstr "" msgstr ""
#: ecommerce/static/js/views/course_form_view.js:75 #: ecommerce/static/js/views/course_form_view.js:72
msgid "" msgid ""
"Paid certificate track with initial verification and Verified Certificate, " "Paid certificate track with initial verification and Verified Certificate, "
"and option to purchase credit" "and option to purchase credit"
......
...@@ -7,14 +7,14 @@ msgid "" ...@@ -7,14 +7,14 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-07-12 10:32+0000\n" "POT-Creation-Date: 2017-07-16 17:22-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Language: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: ecommerce/core/admin.py #: ecommerce/core/admin.py
...@@ -2279,6 +2279,10 @@ msgid "Pay with PayPal" ...@@ -2279,6 +2279,10 @@ msgid "Pay with PayPal"
msgstr "Päý wïth PäýPäl Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" msgstr "Päý wïth PäýPäl Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
#: ecommerce/templates/oscar/basket/partials/client_side_checkout_basket.html #: ecommerce/templates/oscar/basket/partials/client_side_checkout_basket.html
msgid "Pay with Apple Pay"
msgstr "Päý wïth Àpplé Päý Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#"
#: ecommerce/templates/oscar/basket/partials/client_side_checkout_basket.html
msgid "card holder information" msgid "card holder information"
msgstr "çärd höldér ïnförmätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" msgstr "çärd höldér ïnförmätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
......
...@@ -7,14 +7,14 @@ msgid "" ...@@ -7,14 +7,14 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-07-05 12:09-0400\n" "POT-Creation-Date: 2017-07-16 17:22-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Language: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: ecommerce/static/js/models/coupon_model.js #: ecommerce/static/js/models/coupon_model.js
...@@ -270,6 +270,22 @@ msgstr "" ...@@ -270,6 +270,22 @@ msgstr ""
"Çäütïön! Ûsïng thé ßäçk ßüttön ön thïs pägé mäý çäüsé ýöü tö ßé çhärgéd " "Çäütïön! Ûsïng thé ßäçk ßüttön ön thïs pägé mäý çäüsé ýöü tö ßé çhärgéd "
"ägäïn. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#" "ägäïn. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#"
#. Translators: Do not translate "Apple Pay".
#: ecommerce/static/js/payment_processors/cybersource.js
msgid ""
"Apple Pay is not available at this time. Please try another payment method."
msgstr ""
"Àpplé Päý ïs nöt äväïläßlé ät thïs tïmé. Pléäsé trý änöthér päýmént méthöd. "
"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#"
#: ecommerce/static/js/payment_processors/cybersource.js
msgid ""
"An error occurred while processing your payment. You have NOT been charged. "
"Please try again, or select another payment method."
msgstr ""
"Àn érrör öççürréd whïlé pröçéssïng ýöür päýmént. Ýöü hävé NÖT ßéén çhärgéd. "
"Pléäsé trý ägäïn, ör séléçt änöthér päýmént méthöd. Ⱡ'σяє#"
#: ecommerce/static/js/utils/utils.js #: ecommerce/static/js/utils/utils.js
msgid "Trailing comma not allowed." msgid "Trailing comma not allowed."
msgstr "Träïlïng çömmä nöt ällöwéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" msgstr "Träïlïng çömmä nöt ällöwéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
......
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-17 02:12
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0038_siteconfiguration_discovery_api_url'),
]
operations = [
migrations.AddField(
model_name='siteconfiguration',
name='enable_apple_pay',
field=models.BooleanField(default=False, verbose_name='Enable Apple Pay'),
),
migrations.AlterField(
model_name='siteconfiguration',
name='discovery_api_url',
field=models.URLField(blank=True, verbose_name='Discovery API URL'),
),
]
...@@ -176,6 +176,11 @@ class SiteConfiguration(models.Model): ...@@ -176,6 +176,11 @@ class SiteConfiguration(models.Model):
verbose_name=_('Discovery API URL'), verbose_name=_('Discovery API URL'),
blank=True, blank=True,
) )
enable_apple_pay = models.BooleanField(
# Translators: Do not translate "Apple Pay"
verbose_name=_('Enable Apple Pay'),
default=False
)
@property @property
def payment_processors_set(self): def payment_processors_set(self):
......
...@@ -52,11 +52,13 @@ class EdxOrderPlacementMixin(OrderPlacementMixin): ...@@ -52,11 +52,13 @@ class EdxOrderPlacementMixin(OrderPlacementMixin):
linked to the order when it is saved later on. linked to the order when it is saved later on.
""" """
handled_processor_response = self.payment_processor.handle_processor_response(response, basket=basket) handled_processor_response = self.payment_processor.handle_processor_response(response, basket=basket)
self.record_payment(basket, handled_processor_response)
def record_payment(self, basket, handled_processor_response):
track_segment_event(basket.site, basket.owner, 'Payment Info Entered', {'checkout_id': basket.order_number}) track_segment_event(basket.site, basket.owner, 'Payment Info Entered', {'checkout_id': basket.order_number})
source_type, __ = SourceType.objects.get_or_create(name=self.payment_processor.NAME) source_type, __ = SourceType.objects.get_or_create(name=self.payment_processor.NAME)
total = handled_processor_response.total total = handled_processor_response.total
reference = handled_processor_response.transaction_id reference = handled_processor_response.transaction_id
source = Source( source = Source(
source_type=source_type, source_type=source_type,
currency=handled_processor_response.currency, currency=handled_processor_response.currency,
...@@ -66,14 +68,11 @@ class EdxOrderPlacementMixin(OrderPlacementMixin): ...@@ -66,14 +68,11 @@ class EdxOrderPlacementMixin(OrderPlacementMixin):
label=handled_processor_response.card_number, label=handled_processor_response.card_number,
card_type=handled_processor_response.card_type card_type=handled_processor_response.card_type
) )
event_type, __ = PaymentEventType.objects.get_or_create(name=PaymentEventTypeName.PAID) event_type, __ = PaymentEventType.objects.get_or_create(name=PaymentEventTypeName.PAID)
payment_event = PaymentEvent(event_type=event_type, amount=total, reference=reference, payment_event = PaymentEvent(event_type=event_type, amount=total, reference=reference,
processor_name=self.payment_processor.NAME) processor_name=self.payment_processor.NAME)
self.add_payment_source(source) self.add_payment_source(source)
self.add_payment_event(payment_event) self.add_payment_event(payment_event)
audit_log( audit_log(
'payment_received', 'payment_received',
amount=payment_event.amount, amount=payment_event.amount,
......
...@@ -21,7 +21,7 @@ class Order(AbstractOrder): ...@@ -21,7 +21,7 @@ class Order(AbstractOrder):
class PaymentEvent(AbstractPaymentEvent): class PaymentEvent(AbstractPaymentEvent):
processor_name = models.CharField(_("Payment Processor"), max_length=32, blank=True, null=True) processor_name = models.CharField(_('Payment Processor'), max_length=32, blank=True, null=True)
class Line(AbstractLine): class Line(AbstractLine):
......
...@@ -7,19 +7,23 @@ from django.utils.translation import ugettext_lazy as _ ...@@ -7,19 +7,23 @@ from django.utils.translation import ugettext_lazy as _
CARD_TYPES = { CARD_TYPES = {
'american_express': { 'american_express': {
'display_name': _('American Express'), 'display_name': _('American Express'),
'cybersource_code': '003' 'cybersource_code': '003',
'apple_pay_network': 'amex',
}, },
'discover': { 'discover': {
'display_name': _('Discover'), 'display_name': _('Discover'),
'cybersource_code': '004' 'cybersource_code': '004',
'apple_pay_network': 'discover',
}, },
'mastercard': { 'mastercard': {
'display_name': _('MasterCard'), 'display_name': _('MasterCard'),
'cybersource_code': '002' 'cybersource_code': '002',
'apple_pay_network': 'mastercard',
}, },
'visa': { 'visa': {
'display_name': _('Visa'), 'display_name': _('Visa'),
'cybersource_code': '001' 'cybersource_code': '001',
'apple_pay_network': 'visa',
}, },
} }
...@@ -38,3 +42,8 @@ PAYPAL_LOCALES = { ...@@ -38,3 +42,8 @@ PAYPAL_LOCALES = {
'en': 'US', 'en': 'US',
'es': 'MX', 'es': 'MX',
} }
APPLE_PAY_CYBERSOURCE_CARD_TYPE_MAP = {
value['apple_pay_network']: value['cybersource_code'] for value in six.itervalues(CARD_TYPES) if
'cybersource_code' in value
}
""" CyberSource payment processing. """ """ CyberSource payment processing. """
from __future__ import unicode_literals from __future__ import unicode_literals
import base64
import datetime import datetime
import json
import logging import logging
import uuid import uuid
from decimal import Decimal from decimal import Decimal
...@@ -17,10 +19,10 @@ from zeep.wsse import UsernameToken ...@@ -17,10 +19,10 @@ from zeep.wsse import UsernameToken
from ecommerce.core.constants import ISO_8601_FORMAT from ecommerce.core.constants import ISO_8601_FORMAT
from ecommerce.core.url_utils import get_ecommerce_url from ecommerce.core.url_utils import get_ecommerce_url
from ecommerce.extensions.checkout.utils import get_receipt_page_url from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.payment.constants import CYBERSOURCE_CARD_TYPE_MAP from ecommerce.extensions.payment.constants import APPLE_PAY_CYBERSOURCE_CARD_TYPE_MAP, CYBERSOURCE_CARD_TYPE_MAP
from ecommerce.extensions.payment.exceptions import ( from ecommerce.extensions.payment.exceptions import (
DuplicateReferenceNumber, InvalidCybersourceDecision, InvalidSignatureError, DuplicateReferenceNumber, InvalidCybersourceDecision, InvalidSignatureError, PartialAuthorizationError,
PartialAuthorizationError, PCIViolation, ProcessorMisconfiguredError PCIViolation, ProcessorMisconfiguredError
) )
from ecommerce.extensions.payment.helpers import sign from ecommerce.extensions.payment.helpers import sign
from ecommerce.extensions.payment.processors import BaseClientSidePaymentProcessor, HandledProcessorResponse from ecommerce.extensions.payment.processors import BaseClientSidePaymentProcessor, HandledProcessorResponse
...@@ -68,6 +70,14 @@ class Cybersource(BaseClientSidePaymentProcessor): ...@@ -68,6 +70,14 @@ class Cybersource(BaseClientSidePaymentProcessor):
self.sop_secret_key = configuration.get('sop_secret_key') self.sop_secret_key = configuration.get('sop_secret_key')
self.sop_payment_page_url = configuration.get('sop_payment_page_url') self.sop_payment_page_url = configuration.get('sop_payment_page_url')
# Apple Pay configuration
self.apple_pay_enabled = self.site.siteconfiguration.enable_apple_pay
self.apple_pay_merchant_identifier = configuration.get('apple_pay_merchant_identifier', '')
self.apple_pay_merchant_id_domain_association = configuration.get(
'apple_pay_merchant_id_domain_association', '').strip()
self.apple_pay_merchant_id_certificate_path = configuration.get('apple_pay_merchant_id_certificate_path', '')
self.apple_pay_country_code = configuration.get('apple_pay_country_code', '')
@property @property
def cancel_page_url(self): def cancel_page_url(self):
return get_ecommerce_url(self.configuration['cancel_checkout_path']) return get_ecommerce_url(self.configuration['cancel_checkout_path'])
...@@ -160,12 +170,12 @@ class Cybersource(BaseClientSidePaymentProcessor): ...@@ -160,12 +170,12 @@ class Cybersource(BaseClientSidePaymentProcessor):
if self.send_level_2_3_details: if self.send_level_2_3_details:
parameters['amex_data_taa1'] = site.name parameters['amex_data_taa1'] = site.name
parameters['purchasing_level'] = '3' parameters['purchasing_level'] = '3'
parameters['line_item_count'] = basket.lines.count() parameters['line_item_count'] = basket.all_lines().count()
# Note (CCB): This field (purchase order) is required for Visa; # Note (CCB): This field (purchase order) is required for Visa;
# but, is not actually used by us/exposed on the order form. # but, is not actually used by us/exposed on the order form.
parameters['user_po'] = 'BLANK' parameters['user_po'] = 'BLANK'
for index, line in enumerate(basket.lines.all()): for index, line in enumerate(basket.all_lines()):
parameters['item_{}_code'.format(index)] = line.product.get_product_class().slug parameters['item_{}_code'.format(index)] = line.product.get_product_class().slug
parameters['item_{}_discount_amount '.format(index)] = str(line.discount_value) parameters['item_{}_discount_amount '.format(index)] = str(line.discount_value)
# Note (CCB): This indicates that the total_amount field below includes tax. # Note (CCB): This indicates that the total_amount field below includes tax.
...@@ -348,3 +358,102 @@ class Cybersource(BaseClientSidePaymentProcessor): ...@@ -348,3 +358,102 @@ class Cybersource(BaseClientSidePaymentProcessor):
'Failed to issue CyberSource credit for order [{order_number}]. ' 'Failed to issue CyberSource credit for order [{order_number}]. '
'Complete response has been recorded in entry [{response_id}]'.format( 'Complete response has been recorded in entry [{response_id}]'.format(
order_number=order.number, response_id=ppr.id)) order_number=order.number, response_id=ppr.id))
def request_apple_pay_authorization(self, basket, billing_address, payment_token):
"""
Authorizes an Apple Pay payment.
For details on the process, see the CyberSource Simple Order API documentation at
https://www.cybersource.com/developers/integration_methods/apple_pay/.
Args:
basket (Basket)
billing_address (BillingAddress)
payment_token (dict)
Returns:
HandledProcessorResponse
Raises:
GatewayError
"""
try:
client = Client(self.soap_api_url, wsse=UsernameToken(self.merchant_id, self.transaction_key))
card_type = APPLE_PAY_CYBERSOURCE_CARD_TYPE_MAP[payment_token['paymentMethod']['network'].lower()]
bill_to = {
'firstName': billing_address.first_name,
'lastName': billing_address.last_name,
'street1': billing_address.line1,
'street2': billing_address.line2,
'city': billing_address.line4,
'state': billing_address.state,
'postalCode': billing_address.postcode,
'country': billing_address.country.iso_3166_1_a2,
'email': basket.owner.email,
}
purchase_totals = {
'currency': basket.currency,
'grandTotalAmount': str(basket.total_incl_tax),
}
encrypted_payment = {
'descriptor': 'RklEPUNPTU1PTi5BUFBMRS5JTkFQUC5QQVlNRU5U',
'data': base64.b64encode(json.dumps(payment_token['paymentData'])),
'encoding': 'Base64',
}
card = {
'cardType': card_type,
}
auth_service = {
'run': 'true',
}
capture_service = {
'run': 'true',
}
item = [{
'id': index,
'productCode': line.product.get_product_class().slug,
'productName': clean_field_value(line.product.title),
'quantity': line.quantity,
'productSKU': line.stockrecord.partner_sku,
'taxAmount': str(line.line_tax),
'unitPrice': str(line.unit_price_incl_tax),
} for index, line in enumerate(basket.all_lines())]
response = client.service.runTransaction(
merchantID=self.merchant_id,
merchantReferenceCode=basket.order_number,
billTo=bill_to,
purchaseTotals=purchase_totals,
encryptedPayment=encrypted_payment,
card=card,
ccAuthService=auth_service,
ccCaptureService=capture_service,
paymentSolution='001',
item=item,
)
except:
msg = 'An error occurred while authorizing an Apple Pay (via CyberSource) for basket [{}]'.format(basket.id)
logger.exception(msg)
raise GatewayError(msg)
request_id = response.requestID
ppr = self.record_processor_response(serialize_object(response), transaction_id=request_id, basket=basket)
if response.decision == 'ACCEPT':
currency = basket.currency
total = basket.total_incl_tax
transaction_id = request_id
return HandledProcessorResponse(
transaction_id=transaction_id,
total=total,
currency=currency,
card_number='Apple Pay',
card_type=CYBERSOURCE_CARD_TYPE_MAP.get(card_type)
)
else:
msg = ('CyberSource rejected an Apple Pay authorization request for basket [{basket_id}]. '
'Complete response has been recorded in entry [{response_id}]')
msg = msg.format(basket_id=basket.id, response_id=ppr.id)
raise GatewayError(msg)
...@@ -331,6 +331,62 @@ class CybersourceMixin(PaymentEventsMixin): ...@@ -331,6 +331,62 @@ class CybersourceMixin(PaymentEventsMixin):
return expected return expected
def mock_authorization_response(self, accepted=True):
decision = 'ACCEPT' if accepted else 'REJECTED'
reason_code = 100 if accepted else 102
url = 'https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor'
body = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsu:Timestamp
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
wsu:Id="Timestamp-2033980704">
<wsu:Created>2017-07-09T20:42:17.984Z</wsu:Created>
</wsu:Timestamp>
</wsse:Security>
</soap:Header>
<soap:Body>
<c:replyMessage xmlns:c="urn:schemas-cybersource-com:transaction-data-1.115">
<c:merchantReferenceCode>EDX-100045</c:merchantReferenceCode>
<c:requestID>4996329373316728804010</c:requestID>
<c:decision>{decision}</c:decision>
<c:reasonCode>{reason_code}</c:reasonCode>
<c:requestToken>
Ahj//wSTDtn/tVgRrNKqKhbFg0cuWjFgyYNkuHIlfeUBS4ciV95dIHTVjgtDJpJlukB2NEAYMZMO2f+1WBGs0qoAqQu/
</c:requestToken>
<c:purchaseTotals>
<c:currency>USD</c:currency>
</c:purchaseTotals>
<c:ccAuthReply>
<c:reasonCode>{reason_code}</c:reasonCode>
<c:amount>99.00</c:amount>
<c:authorizationCode>831000</c:authorizationCode>
<c:avsCode>Y</c:avsCode>
<c:avsCodeRaw>Y</c:avsCodeRaw>
<c:authorizedDateTime>2017-07-09T20:42:17Z</c:authorizedDateTime>
<c:processorResponse>000</c:processorResponse>
<c:paymentNetworkTransactionID>558196000003814</c:paymentNetworkTransactionID>
<c:cardCategory>A</c:cardCategory>
</c:ccAuthReply>
<c:ccCaptureReply>
<c:reasonCode>{reason_code}</c:reasonCode>
<c:requestDateTime>2017-07-09T20:42:17Z</c:requestDateTime>
<c:amount>99.00</c:amount>
<c:reconciliationID>10499410206</c:reconciliationID>
</c:ccCaptureReply>
</c:replyMessage>
</soap:Body>
</soap:Envelope>
""".format(
decision=decision,
reason_code=reason_code,
)
responses.add(responses.POST, url, body=body, content_type='text/xml')
return body
@ddt.ddt @ddt.ddt
class CybersourceNotificationTestsMixin(CybersourceMixin): class CybersourceNotificationTestsMixin(CybersourceMixin):
......
...@@ -280,3 +280,85 @@ class CybersourceTests(CybersourceMixin, PaymentProcessorTestCaseMixin, TestCase ...@@ -280,3 +280,85 @@ class CybersourceTests(CybersourceMixin, PaymentProcessorTestCaseMixin, TestCase
def test_get_template_name(self): def test_get_template_name(self):
""" Verify the method returns the path to the client-side template. """ """ Verify the method returns the path to the client-side template. """
self.assertEqual(self.processor.get_template_name(), 'payment/cybersource.html') self.assertEqual(self.processor.get_template_name(), 'payment/cybersource.html')
@responses.activate
def test_request_apple_pay_authorization(self):
""" The method should authorize and settle an Apple Pay payment with CyberSource. """
basket = factories.create_basket()
basket.owner = self.create_user()
basket.site = self.site
basket.save()
billing_address = factories.BillingAddressFactory()
payment_token = {
'paymentData': {
'version': 'EC_v1',
'data': 'fake-data',
'signature': 'fake-signature',
'header': {
'ephemeralPublicKey': 'fake-key',
'publicKeyHash': 'fake-hash',
'transactionId': 'abc123'
}
},
'paymentMethod': {
'displayName': 'AmEx 1086',
'network': 'AmEx',
'type': 'credit'
},
'transactionIdentifier': 'DEADBEEF'
}
self.mock_cybersource_wsdl()
self.mock_authorization_response(accepted=True)
actual = self.processor.request_apple_pay_authorization(basket, billing_address, payment_token)
self.assertEqual(actual.total, basket.total_incl_tax)
self.assertEqual(actual.currency, basket.currency)
self.assertEqual(actual.card_number, 'Apple Pay')
self.assertEqual(actual.card_type, 'american_express')
@responses.activate
def test_request_apple_pay_authorization_rejected(self):
""" The method should raise GatewayError if CyberSource rejects the payment. """
self.mock_cybersource_wsdl()
self.mock_authorization_response(accepted=False)
basket = factories.create_basket()
basket.owner = self.create_user()
basket.site = self.site
basket.save()
billing_address = factories.BillingAddressFactory()
payment_token = {
'paymentData': {
'version': 'EC_v1',
'data': 'fake-data',
'signature': 'fake-signature',
'header': {
'ephemeralPublicKey': 'fake-key',
'publicKeyHash': 'fake-hash',
'transactionId': 'abc123'
}
},
'paymentMethod': {
'displayName': 'AmEx 1086',
'network': 'AmEx',
'type': 'credit'
},
'transactionIdentifier': 'DEADBEEF'
}
with self.assertRaises(GatewayError):
self.processor.request_apple_pay_authorization(basket, billing_address, payment_token)
def test_request_apple_pay_authorization_error(self):
""" The method should raise GatewayError if an error occurs while authorizing payment. """
basket = factories.create_basket()
basket.owner = self.create_user()
basket.site = self.site
basket.save()
with mock.patch('zeep.Client.__init__', side_effect=Exception):
with self.assertRaises(GatewayError):
self.processor.request_apple_pay_authorization(basket, None, None)
...@@ -5,6 +5,7 @@ import json ...@@ -5,6 +5,7 @@ import json
import ddt import ddt
import mock import mock
import responses
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from freezegun import freeze_time from freezegun import freeze_time
...@@ -13,7 +14,10 @@ from oscar.core.loading import get_class, get_model ...@@ -13,7 +14,10 @@ from oscar.core.loading import get_class, get_model
from oscar.test import factories from oscar.test import factories
from ecommerce.core.url_utils import get_lms_url from ecommerce.core.url_utils import get_lms_url
from ecommerce.extensions.api.serializers import OrderSerializer
from ecommerce.extensions.order.constants import PaymentEventTypeName
from ecommerce.extensions.payment.exceptions import InvalidBasketError, InvalidSignatureError from ecommerce.extensions.payment.exceptions import InvalidBasketError, InvalidSignatureError
from ecommerce.extensions.payment.processors.cybersource import Cybersource
from ecommerce.extensions.payment.tests.mixins import CybersourceMixin, CybersourceNotificationTestsMixin from ecommerce.extensions.payment.tests.mixins import CybersourceMixin, CybersourceNotificationTestsMixin
from ecommerce.extensions.payment.views.cybersource import CybersourceInterstitialView from ecommerce.extensions.payment.views.cybersource import CybersourceInterstitialView
from ecommerce.tests.testcases import TestCase from ecommerce.tests.testcases import TestCase
...@@ -26,10 +30,18 @@ OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator') ...@@ -26,10 +30,18 @@ OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator')
PaymentEvent = get_model('order', 'PaymentEvent') PaymentEvent = get_model('order', 'PaymentEvent')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
Selector = get_class('partner.strategy', 'Selector') Selector = get_class('partner.strategy', 'Selector')
Source = get_model('payment', 'Source')
post_checkout = get_class('checkout.signals', 'post_checkout') post_checkout = get_class('checkout.signals', 'post_checkout')
class LoginMixin(object):
def setUp(self):
super(LoginMixin, self).setUp()
self.user = self.create_user()
self.client.login(username=self.user.username, password=self.password)
@ddt.ddt @ddt.ddt
class CybersourceSubmitViewTests(CybersourceMixin, TestCase): class CybersourceSubmitViewTests(CybersourceMixin, TestCase):
path = reverse('cybersource:submit') path = reverse('cybersource:submit')
...@@ -241,3 +253,153 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa ...@@ -241,3 +253,153 @@ class CybersourceInterstitialViewTests(CybersourceNotificationTestsMixin, TestCa
with mock.patch.object(self.view, '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)
class ApplePayMerchantDomainAssociationViewTests(LoginMixin, TestCase):
url = reverse('apple_pay_domain_association')
def assert_response_matches(self, response, expected_status_code, expected_content):
self.assertEqual(response.status_code, expected_status_code)
self.assertEqual(response.content, expected_content)
self.assertEqual(response['Content-Type'], 'text/plain')
def test_get(self):
""" The view should return the the merchant domain association verification data. """
response = self.client.get(self.url)
self.assert_response_matches(response, 200, settings.PAYMENT_PROCESSOR_CONFIG['edx']['cybersource'][
'apple_pay_merchant_id_domain_association'])
def test_get_with_configuration_error(self):
""" The view should return HTTP 501 if Apple Pay is not properly configured. """
settings.PAYMENT_PROCESSOR_CONFIG['edx']['cybersource'][
'apple_pay_merchant_id_domain_association'] = ''
response = self.client.get(self.url)
content = 'Apple Pay is not configured for [{}].'.format(self.site.domain)
self.assert_response_matches(response, 501, content)
@ddt.ddt
class ApplePayStartSessionViewTests(LoginMixin, TestCase):
url = reverse('cybersource:apple_pay:start_session')
@ddt.data(
(200, {'foo': 'bar'}),
(500, {'error': 'Failure!'})
)
@ddt.unpack
@responses.activate
def test_post(self, status, body):
""" The view should POST to the given URL and return the response. """
url = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'
body = json.dumps(body)
responses.add(responses.POST, url, body=body, status=status, content_type=JSON)
response = self.client.post(self.url, json.dumps({'url': url}), JSON)
self.assertEqual(response.status_code, status)
self.assertEqual(response.content, body)
def test_post_without_url(self):
""" The view should return HTTP 400 if no url parameter is posted. """
response = self.client.post(self.url)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'url is required'})
@ddt.ddt
class CybersourceApplePayAuthorizationViewTests(LoginMixin, CybersourceMixin, TestCase):
url = reverse('cybersource:apple_pay:authorize')
def generate_post_data(self):
address = factories.BillingAddressFactory()
return {
'billingContact': {
'addressLines': [
address.line1,
address.line1
],
'administrativeArea': address.state,
'country': address.country.printable_name,
'countryCode': address.country.iso_3166_1_a2,
'familyName': self.user.last_name,
'givenName': self.user.first_name,
'locality': address.line4,
'postalCode': address.postcode,
},
'shippingContact': {
'emailAddress': self.user.email,
'familyName': self.user.last_name,
'givenName': self.user.first_name,
},
'token': {
'paymentData': {
'version': 'EC_v1',
'data': 'fake-data',
'signature': 'fake-signature',
'header': {
'ephemeralPublicKey': 'fake-key',
'publicKeyHash': 'fake-hash',
'transactionId': 'abc123'
}
},
'paymentMethod': {
'displayName': 'AmEx 1086',
'network': 'AmEx',
'type': 'credit'
},
'transactionIdentifier': 'DEADBEEF'
}
}
@responses.activate
def test_post(self):
""" The view should authorize and settle payment at CyberSource, and create an order. """
data = self.generate_post_data()
basket = factories.create_basket()
basket.owner = self.user
basket.strategy = Selector().strategy()
basket.site = self.site
basket.save()
self.mock_cybersource_wsdl()
self.mock_authorization_response(accepted=True)
response = self.client.post(self.url, json.dumps(data), JSON)
self.assertEqual(response.status_code, 201)
PaymentProcessorResponse.objects.get(basket=basket)
order = Order.objects.all().first()
total = order.total_incl_tax
self.assertEqual(response.data, OrderSerializer(order, context={'request': self.request}).data)
order.payment_events.get(event_type__code='paid', amount=total)
Source.objects.get(
source_type__name=Cybersource.NAME, currency=order.currency, amount_allocated=total, amount_debited=total,
label='Apple Pay')
PaymentEvent.objects.get(event_type__name=PaymentEventTypeName.PAID, amount=total,
processor_name=Cybersource.NAME)
@responses.activate
def test_post_with_rejected_payment(self):
""" The view should return an error if CyberSource rejects payment. """
data = self.generate_post_data()
self.mock_cybersource_wsdl()
self.mock_authorization_response(accepted=False)
response = self.client.post(self.url, json.dumps(data), JSON)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.data, {'error': 'payment_failed'})
def test_post_with_invalid_billing_address(self):
""" The view should return an error if the billing address is invalid. """
data = self.generate_post_data()
data['billingContact'] = {}
response = self.client.post(self.url, json.dumps(data), JSON)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'billing_address_invalid'})
def test_post_without_payment_token(self):
""" The view should return an error if no payment token is provided. """
data = self.generate_post_data()
data['token'] = {}
response = self.client.post(self.url, json.dumps(data), JSON)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'token_missing'})
...@@ -2,7 +2,12 @@ from django.conf.urls import include, url ...@@ -2,7 +2,12 @@ from django.conf.urls import include, url
from ecommerce.extensions.payment.views import PaymentFailedView, SDNFailure, cybersource, paypal from ecommerce.extensions.payment.views import PaymentFailedView, SDNFailure, cybersource, paypal
CYBERSOURCE_APPLE_PAY_URLS = [
url(r'^authorize/$', cybersource.CybersourceApplePayAuthorizationView.as_view(), name='authorize'),
url(r'^start-session/$', cybersource.ApplePayStartSessionView.as_view(), name='start_session'),
]
CYBERSOURCE_URLS = [ CYBERSOURCE_URLS = [
url(r'^apple-pay/', include(CYBERSOURCE_APPLE_PAY_URLS, namespace='apple_pay')),
url(r'^redirect/$', cybersource.CybersourceInterstitialView.as_view(), name='redirect'), url(r'^redirect/$', cybersource.CybersourceInterstitialView.as_view(), name='redirect'),
url(r'^submit/$', cybersource.CybersourceSubmitView.as_view(), name='submit'), url(r'^submit/$', cybersource.CybersourceSubmitView.as_view(), name='submit'),
] ]
......
"""Oscar-specific settings""" """Oscar-specific settings"""
from __future__ import absolute_import from __future__ import absolute_import
from os.path import abspath, dirname, join
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from oscar import get_core_apps from oscar import get_core_apps
from oscar.defaults import * from oscar.defaults import *
...@@ -117,6 +114,10 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -117,6 +114,10 @@ PAYMENT_PROCESSOR_CONFIG = {
'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH, 'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH,
'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH, 'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH,
'send_level_2_3_details': True, 'send_level_2_3_details': True,
'apple_pay_merchant_identifier': '',
'apple_pay_merchant_id_domain_association': '',
'apple_pay_merchant_id_certificate_path': '',
'apple_pay_country_code': '',
}, },
'paypal': { 'paypal': {
# 'mode' can be either 'sandbox' or 'live' # 'mode' can be either 'sandbox' or 'live'
......
...@@ -3,7 +3,7 @@ import datetime ...@@ -3,7 +3,7 @@ import datetime
import os import os
import platform import platform
from logging.handlers import SysLogHandler from logging.handlers import SysLogHandler
from os.path import basename, normpath from os.path import abspath, basename, dirname, join, normpath
from sys import path from sys import path
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
......
...@@ -3,7 +3,6 @@ from __future__ import absolute_import ...@@ -3,7 +3,6 @@ from __future__ import absolute_import
from urlparse import urljoin from urlparse import urljoin
from ecommerce.settings._debug_toolbar import *
from ecommerce.settings.base import * from ecommerce.settings.base import *
# DEBUG CONFIGURATION # DEBUG CONFIGURATION
...@@ -69,22 +68,32 @@ EDX_API_KEY = 'replace-me' ...@@ -69,22 +68,32 @@ EDX_API_KEY = 'replace-me'
# PAYMENT PROCESSING # PAYMENT PROCESSING
PAYMENT_PROCESSOR_CONFIG = { PAYMENT_PROCESSOR_CONFIG = {
'edx': { 'edx': {
# NOTE: The same profile information is used here to appease the payment processor class.
# Only Silent Order POST is actually used.
'cybersource': { 'cybersource': {
'merchant_id': 'edx_org',
'transaction_key': '/yIJJejEGoNNcecTyxC9ZD0wR2ZjkkKuOaZnq2BGMGIGQIOKA1rBR009OuvKbPW4J1KLb15BMlaoiUXoj/8/Fp6dy33/aHAU0+yGKcEMxyYXQOBPKjuoChIlMRVkrtWZqP9shGxw1jwHNovmGrvd2ULRIn21Rsq6YnHie7lLLRhXyY2MjnFXfv75eH2rFwfi4hBPbVPvx/r8PwgFIh5otAzsgyIlBjaKJkzbNXd5qCOdNFSBcPcJps3YgVH0ASleI/SZp+Ckuyotd+EhzK0tOehPJAm3L03lkPNeFX9lcemuRkeV53V3nvobn3GaX0td4FAEe8CZBn+IpFC2PoK0tw==',
'soap_api_url': 'https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.115.wsdl', 'soap_api_url': 'https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.115.wsdl',
'merchant_id': 'fake-merchant-id', 'profile_id': '00D31C4B-4E8F-4E9F-A6B9-1DB8C7C86223',
'transaction_key': 'fake-transaction-key', 'access_key': '90a39534dc513e8a81222b158378dda1',
'profile_id': 'fake-profile-id', 'secret_key': 'ff09d545ddbe4f1e908cc47e3cceb30e4e9ff57a1fe0493392b69a0b75f8ac3df7840f89131d46faa4487071d53576d25047ebb39e9b4af18e9fb5ee1d4f1f66fdb711284c844c4c82bd24f168781e786ecf8b2d3dba4ab5b543c188ca5728e00b8ace43cca14cefbb605ecdc0706eda4cd50785d5754fd691426ddff03fcc7b',
'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key',
'payment_page_url': 'https://testsecureacceptance.cybersource.com/pay', 'payment_page_url': 'https://testsecureacceptance.cybersource.com/pay',
'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH, 'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH,
'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH, 'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH,
'send_level_2_3_details': True, 'send_level_2_3_details': True,
'sop_profile_id': '00D31C4B-4E8F-4E9F-A6B9-1DB8C7C86223',
'sop_access_key': '90a39534dc513e8a81222b158378dda1',
'sop_secret_key': 'ff09d545ddbe4f1e908cc47e3cceb30e4e9ff57a1fe0493392b69a0b75f8ac3df7840f89131d46faa4487071d53576d25047ebb39e9b4af18e9fb5ee1d4f1f66fdb711284c844c4c82bd24f168781e786ecf8b2d3dba4ab5b543c188ca5728e00b8ace43cca14cefbb605ecdc0706eda4cd50785d5754fd691426ddff03fcc7b',
'sop_payment_page_url': 'https://testsecureacceptance.cybersource.com/silent/pay',
'apple_pay_merchant_identifier': '',
'apple_pay_merchant_id_domain_association': '',
'apple_pay_merchant_id_certificate_path': '',
'apple_pay_country_code': '',
}, },
'paypal': { 'paypal': {
'mode': 'sandbox', 'mode': 'sandbox',
'client_id': 'fake-client-id', 'client_id': 'AVcS4ZWEk7IPqaJibex3bCR0_lykVQ2BHdGz6JWVik0PKWGTOQzWMBOHRppPwFXMCPUqRsoBUDSE-ro5',
'client_secret': 'fake-client-secret', 'client_secret': 'EHNgP4mXL5mI54DQI1-EgXo6y0BDUzj5x1_8gQD0dNWSWS6pcLqlmGq8f5En6oos0z2L37a_EJ27mJ_a',
'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH, 'receipt_path': PAYMENT_PROCESSOR_RECEIPT_PATH,
'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH, 'cancel_checkout_path': PAYMENT_PROCESSOR_CANCEL_PATH,
'error_path': PAYMENT_PROCESSOR_ERROR_PATH, 'error_path': PAYMENT_PROCESSOR_ERROR_PATH,
......
...@@ -88,6 +88,10 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -88,6 +88,10 @@ PAYMENT_PROCESSOR_CONFIG = {
'sop_access_key': 'sop-fake-access-key', 'sop_access_key': 'sop-fake-access-key',
'sop_secret_key': 'sop-fake-secret-key', 'sop_secret_key': 'sop-fake-secret-key',
'sop_payment_page_url': 'https://sop-replace-me/', 'sop_payment_page_url': 'https://sop-replace-me/',
'apple_pay_merchant_identifier': 'merchant.com.example',
'apple_pay_merchant_id_domain_association': 'fake-merchant-id-domain-association',
'apple_pay_merchant_id_certificate_path': '',
'apple_pay_country_code': 'US',
}, },
'paypal': { 'paypal': {
'mode': 'sandbox', 'mode': 'sandbox',
......
...@@ -18,6 +18,7 @@ require.config({ ...@@ -18,6 +18,7 @@ require.config({
models: 'js/models', models: 'js/models',
moment: 'bower_components/moment/moment', moment: 'bower_components/moment/moment',
pages: 'js/pages', pages: 'js/pages',
payment_processors: 'js/payment_processors',
pikaday: 'bower_components/pikaday/pikaday', pikaday: 'bower_components/pikaday/pikaday',
punycode: 'bower_components/punycode/punycode', punycode: 'bower_components/punycode/punycode',
requirejs: 'bower_components/requirejs/require', requirejs: 'bower_components/requirejs/require',
......
...@@ -396,7 +396,9 @@ define([ ...@@ -396,7 +396,9 @@ define([
} }
}); });
$paymentButtons.find('.payment-button').click(function(e) { // NOTE: We only include buttons that have a data-processor-name attribute because we don't want to
// go through the standard checkout process for some payment methods (e.g. Apple Pay).
$paymentButtons.find('.payment-button[data-processor-name]').click(function(e) {
var $btn = $(e.target), var $btn = $(e.target),
deferred = new $.Deferred(), deferred = new $.Deferred(),
promise = deferred.promise(), promise = deferred.promise(),
......
/* global Cybersource */
/** /**
* CyberSource payment processor specific actions. * CyberSource payment processor specific actions.
*/ */
require([ define([
'jquery', 'jquery',
'js-cookie',
'underscore.string',
'pages/basket_page' 'pages/basket_page'
], function($, BasketPage) { ], function($, Cookies, _s, BasketPage) {
'use strict'; 'use strict';
var CyberSourceClient = { return {
init: function() { init: function(config) {
var $paymentForm = $('#paymentForm'), var $paymentForm = $('#paymentForm'),
$pciFields = $('.pci-field', $paymentForm), $pciFields = $('.pci-field', $paymentForm),
cardMap = { cardMap = {
...@@ -19,10 +20,10 @@ require([ ...@@ -19,10 +20,10 @@ require([
discover: '004' discover: '004'
}; };
this.signingUrl = Cybersource.signingUrl; // jshint ignore:line this.signingUrl = config.signingUrl;
// The payment form should post to CyberSource // The payment form should post to CyberSource
$paymentForm.attr('action', Cybersource.postUrl); // jshint ignore:line $paymentForm.attr('action', config.postUrl);
// Add name attributes to the PCI fields // Add name attributes to the PCI fields
$pciFields.each(function() { $pciFields.each(function() {
...@@ -40,6 +41,9 @@ require([ ...@@ -40,6 +41,9 @@ require([
$paymentForm.on('cardType:detected', function(event, data) { $paymentForm.on('cardType:detected', function(event, data) {
$('input[name=card_type]', $paymentForm).val(cardMap[data.type]); $('input[name=card_type]', $paymentForm).val(cardMap[data.type]);
}); });
this.applePayConfig = config.applePay;
this.initializeApplePay();
}, },
/** /**
...@@ -123,10 +127,134 @@ require([ ...@@ -123,10 +127,134 @@ require([
} }
} }
}); });
},
displayErrorMessage: function(message) {
$('#messages').html(_s.sprintf('<div class="alert alert-error">%s<i class="icon-warning-sign"></i></div>',
message));
},
initializeApplePay: function() {
var promise,
self = this;
if (window.ApplePaySession && self.applePayConfig.enabled) {
promise = ApplePaySession.canMakePaymentsWithActiveCard(self.applePayConfig.merchantIdentifier);
promise.then(
function(canMakePayments) {
var applePayBtn = document.getElementById('applePayBtn');
if (canMakePayments) {
console.log('Learner is eligible for Apple Pay');
// Display the button
applePayBtn.style.display = 'inline-flex';
applePayBtn.addEventListener('click', self.onApplePayButtonClicked.bind(self));
} else {
console.log('Apple Pay not setup.');
}
}
);
return promise;
}
// Return an empty promise for callers expecting a promise.
// eslint-disable-next-line no-undef
return Promise.resolve();
},
onApplePayButtonClicked: function(event) {
// Setup the session and its event handlers
this.applePaySession = new ApplePaySession(2, {
countryCode: this.applePayConfig.countryCode,
currencyCode: this.applePayConfig.basketCurrency,
supportedNetworks: ['amex', 'discover', 'visa', 'masterCard'],
merchantCapabilities: ['supports3DS', 'supportsCredit', 'supportsDebit'],
total: {
label: this.applePayConfig.merchantName,
type: 'final',
amount: this.applePayConfig.basketTotal
},
requiredBillingContactFields: ['postalAddress']
});
this.applePaySession.onvalidatemerchant = this.onApplePayValidateMerchant.bind(this);
this.applePaySession.onpaymentauthorized = this.onApplePayPaymentAuthorized.bind(this);
// Let's start the show!
this.applePaySession.begin();
event.preventDefault();
event.stopPropagation();
},
onApplePayValidateMerchant: function(event) {
var self = this;
console.log('Validating merchant...');
$.ajax({
method: 'POST',
url: this.applePayConfig.startSessionUrl,
headers: {
'X-CSRFToken': Cookies.get('ecommerce_csrftoken')
},
data: JSON.stringify({url: event.validationURL}),
contentType: 'application/json',
success: function(data) {
console.log('Merchant validation succeeded.');
console.log(data);
self.applePaySession.completeMerchantValidation(data);
},
error: function(jqXHR, textStatus, errorThrown) {
// Translators: Do not translate "Apple Pay".
var msg = gettext('Apple Pay is not available at this time. Please try another payment method.');
console.log('Merchant validation failed!');
console.log(textStatus);
console.log(errorThrown);
self.applePaySession.abort();
self.displayErrorMessage(msg);
}
});
},
onApplePayPaymentAuthorized: function(event) {
var self = this;
console.log('Submitting Apple Pay payment to CyberSource...');
$.ajax({
method: 'POST',
url: this.applePayConfig.authorizeUrl,
headers: {
'X-CSRFToken': Cookies.get('ecommerce_csrftoken')
},
data: JSON.stringify(event.payment),
contentType: 'application/json',
success: function(data) {
console.log('Successfully submitted Apple Pay payment to CyberSource.');
console.log(data);
self.applePaySession.completePayment(ApplePaySession.STATUS_SUCCESS);
self.redirectToReceipt(data.number);
},
error: function(jqXHR, textStatus, errorThrown) {
var msg = gettext('An error occurred while processing your payment. You have NOT been charged. ' +
'Please try again, or select another payment method.');
console.log('Failed to submit Apple Pay payment to CyberSource!');
console.log(textStatus);
console.log(errorThrown);
self.applePaySession.completePayment(ApplePaySession.STATUS_FAILURE);
self.displayErrorMessage(msg);
}
});
},
/* istanbul ignore next */
redirectToReceipt: function(orderNumber) {
/* istanbul ignore next */
window.location.href = this.applePayConfig.receiptUrl + '?order_number=' + orderNumber;
} }
}; };
$(document).ready(function() {
CyberSourceClient.init();
});
}); });
<div class="payment-form"> <div id="messages"></div>
<div id="paymentForm" class="payment-form">
<button id="applePayBtn" lang="en" style="display:none"
data-track-type="click"
data-track-event="edx.bi.ecommerce.basket.payment_selected"
data-track-category="checkout"
class="payment-button apple-pay-button-with-text apple-pay-button-black-with-text apple-pay-button-with-text"
title="Pay with Apple Pay" type="button">
<span class="text"></span>
<span class="logo"></span>
</button>
<fieldset> <fieldset>
<input type="hidden" name="sdn-check" value="enabled"> <input type="hidden" name="sdn-check" value="enabled">
<div class="form-item"> <div class="form-item">
......
...@@ -277,7 +277,7 @@ define([ ...@@ -277,7 +277,7 @@ define([
beforeEach(function() { beforeEach(function() {
loadFixtures('client-side-checkout-validation.html'); loadFixtures('client-side-checkout-basket.html');
$('#card-expiry-month').append( $('#card-expiry-month').append(
_.reduce(_.toArray(ccExpiryMonths), function(memo, value) { _.reduce(_.toArray(ccExpiryMonths), function(memo, value) {
......
/* istanbul ignore next */
require([
'jquery',
'payment_processors/cybersource'
], function($, CyberSourceClient) {
'use strict';
$(document).ready(function() {
CyberSourceClient.init(window.CyberSourceConfig);
});
});
...@@ -16,8 +16,8 @@ ...@@ -16,8 +16,8 @@
right: 10px; right: 10px;
-webkit-transform: translateY(-50%); -webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%); -ms-transform: translateY(-50%);
transform: translateY(-50%); transform: translateY(-50%);
} }
} }
...@@ -61,24 +61,24 @@ ...@@ -61,24 +61,24 @@
@include text-align(right); @include text-align(right);
} }
.checkout-quantity { .checkout-quantity {
.input-group { .input-group {
display: inline; display: inline;
} }
.quantity { .quantity {
display: inline-block; display: inline-block;
width: 35%; width: 35%;
padding: 3px; padding: 3px;
} }
button.update-button { button.update-button {
display: inline-block; display: inline-block;
vertical-align:top; vertical-align: top;
margin-left: 5px; margin-left: 5px;
width: 40%; width: 40%;
} }
} }
.product-image img { .product-image img {
margin: 0 auto; margin: 0 auto;
...@@ -236,6 +236,70 @@ ...@@ -236,6 +236,70 @@
background: #25B85A; background: #25B85A;
} }
} }
@supports (-webkit-appearance: -apple-pay-button) {
.apple-pay-set-up-button {
display: inline-block;
-webkit-appearance: -apple-pay-button;
-apple-pay-button-type: set-up !important;
}
.apple-pay-button-with-text {
display: inline-block;
-webkit-appearance: -apple-pay-button;
-apple-pay-button-type: buy;
cursor: pointer;
}
.apple-pay-button-with-text > * {
display: none;
}
.apple-pay-button-black-with-text {
-apple-pay-button-style: black;
}
.apple-pay-button-with-text {
--apple-pay-scale: 1; /* (height / 32) */
display: inline-flex;
justify-content: center;
font-size: 12px;
border-radius: 5px;
padding: 0px;
box-sizing: border-box;
min-width: 200px;
min-height: 32px;
max-height: 64px;
}
.apple-pay-button-black-with-text {
background-color: black;
color: white;
}
.apple-pay-button-with-text.apple-pay-button-black-with-text > .logo, {
background-image: -webkit-named-image(apple-pay-logo-white);
background-color: black;
}
.apple-pay-button-with-text > .text {
font-family: -apple-system;
font-size: calc(1em * var(--apple-pay-scale));
font-weight: 300;
align-self: center;
margin-right: calc(2px * var(--apple-pay-scale));
}
.apple-pay-button-with-text > .logo {
width: calc(35px * var(--scale));
height: 100%;
background-size: 100% 60%;
background-repeat: no-repeat;
background-position: 0 50%;
margin-left: calc(2px * var(--apple-pay-scale));
border: none;
}
}
} }
.verification-note { .verification-note {
...@@ -325,6 +389,7 @@ ...@@ -325,6 +389,7 @@
background-color: #fff; background-color: #fff;
width: 120px; width: 120px;
height: 50px; height: 50px;
vertical-align: top;
&.payment-processor-paypal { &.payment-processor-paypal {
background-image: url('/static/images/paypal_logo.png'); background-image: url('/static/images/paypal_logo.png');
...@@ -457,7 +522,7 @@ ...@@ -457,7 +522,7 @@
.quantity-update { .quantity-update {
width: 80px; width: 80px;
float:left; float: left;
} }
fieldset > legend { fieldset > legend {
......
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
<html lang={{ LANGUAGE_CODE }}> <html lang={{ LANGUAGE_CODE }}>
<head> <head>
<link rel="shortcut icon" href="{% static 'images/favicon.ico' %}"/> <link rel="shortcut icon" href="{% static 'images/favicon.ico' %}"/>
<link rel="apple-touch-icon" href="{% static 'images/touch-icon.png' %}">
<link rel="apple-touch-icon" sizes="120x120" href="{% static 'images/touch-icon-2x.png' %}">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'images/touch-icon-3x.png' %}">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock title %}</title> <title>{% block title %}{% endblock title %}</title>
...@@ -61,7 +64,7 @@ ...@@ -61,7 +64,7 @@
<script src="{% static 'bower_components/requirejs/require.js' %}"></script> <script src="{% static 'bower_components/requirejs/require.js' %}"></script>
<script src="{% static 'js/config.js' %}"></script> <script src="{% static 'js/config.js' %}"></script>
{# Note: django-compressor does not recognize the data-main attribute. Load the main script separately. #} Note: django-compressor does not recognize the data-main attribute. Load the main script separately.
<script src="{% static 'js/common.js' %}"></script> <script src="{% static 'js/common.js' %}"></script>
{% endcompress %} {% endcompress %}
......
...@@ -12,7 +12,6 @@ ...@@ -12,7 +12,6 @@
{% include 'edx/partials/_student_navbar.html' %} {% include 'edx/partials/_student_navbar.html' %}
{% endblock navbar %} {% endblock navbar %}
{% block javascript %} {% block javascript %}
<script src="{% static 'js/apps/basket_app.js' %}"></script> <script src="{% static 'js/apps/basket_app.js' %}"></script>
......
...@@ -108,7 +108,7 @@ ...@@ -108,7 +108,7 @@
<div id="payment-method" class="col-md-4 col-sm-7 col-xs-6"> <div id="payment-method" class="col-md-4 col-sm-7 col-xs-6">
<a href="#payment-information" id="payment-method-image" title="{% trans "Pay with a Credit Card" %}" role="button"><img src="/static/images/credit_card_options.png" alt=""></a> <a href="#payment-information" id="payment-method-image" title="{% trans "Pay with a Credit Card" %}" role="button"><img src="/static/images/credit_card_options.png" alt=""></a>
</div> </div>
<div id="payment-processor" class="payment-buttons col-md-3 col-sm-5 col-xs-6" data-basket-id="{{ basket.id }}"> <div id="payment-processor" class="payment-buttons col-md-8 col-sm-5 col-xs-6" data-basket-id="{{ basket.id }}">
{# Translators: Do NOT translate the name PayPal. #} {# Translators: Do NOT translate the name PayPal. #}
<button data-track-type="click" <button data-track-type="click"
data-track-event="edx.bi.ecommerce.basket.payment_selected" data-track-event="edx.bi.ecommerce.basket.payment_selected"
...@@ -119,6 +119,15 @@ ...@@ -119,6 +119,15 @@
title="{% trans "Pay with PayPal" %}" title="{% trans "Pay with PayPal" %}"
type="button"> type="button">
</button> </button>
<button id="applePayBtn" lang="{{ LANGUAGE_CODE|default:"en-us" }}" style="display:none"
data-track-type="click"
data-track-event="edx.bi.ecommerce.basket.payment_selected"
data-track-category="checkout"
class="payment-button apple-pay-button-with-text apple-pay-button-black-with-text apple-pay-button-with-text"
title="{% trans "Pay with Apple Pay" %}" type="button">
<span class="text"></span>
<span class="logo"></span>
</button>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -4,9 +4,20 @@ ...@@ -4,9 +4,20 @@
{# NOTE: Using compress tags here results in the JS not being loaded. #} {# NOTE: Using compress tags here results in the JS not being loaded. #}
{# We have no idea why after multiple hours of investigation. #} {# We have no idea why after multiple hours of investigation. #}
<script> <script>
Cybersource = { window.CyberSourceConfig = {
postUrl: "{{ client_side_payment_processor.client_side_payment_url }}", postUrl: "{{ client_side_payment_processor.client_side_payment_url }}",
signingUrl: "{% url 'cybersource:submit' %}" signingUrl: "{% url 'cybersource:submit' %}",
applePay: {
enabled: {{ client_side_payment_processor.apple_pay_enabled|yesno:'true,false' }},
merchantName: "{{ platform_name }}",
merchantIdentifier: "{{ client_side_payment_processor.apple_pay_merchant_identifier }}",
countryCode: "{{ client_side_payment_processor.apple_pay_country_code }}",
basketCurrency: "{{ basket.currency }}",
basketTotal: "{{ order_total.incl_tax|floatformat:2 }}",
startSessionUrl: "{% url 'cybersource:apple_pay:start_session' %}",
authorizeUrl: "{% url 'cybersource:apple_pay:authorize' %}",
receiptUrl: "{% url 'checkout:receipt' %}"
}
} }
</script> </script>
<script src="{% static 'js/payment_processors/cybersource.js' %}"></script> <script src="{% static 'js/views/cybersource_client_side_checkout.js' %}"></script>
...@@ -13,6 +13,7 @@ from django.views.i18n import JavaScriptCatalog ...@@ -13,6 +13,7 @@ from django.views.i18n import JavaScriptCatalog
from ecommerce.core import views as core_views from ecommerce.core import views as core_views
from ecommerce.core.url_utils import get_lms_dashboard_url from ecommerce.core.url_utils import get_lms_dashboard_url
from ecommerce.core.views import LogoutView from ecommerce.core.views import LogoutView
from ecommerce.extensions.payment.views.cybersource import ApplePayMerchantDomainAssociationView
from ecommerce.extensions.urls import urlpatterns as extensions_patterns from ecommerce.extensions.urls import urlpatterns as extensions_patterns
...@@ -40,7 +41,12 @@ admin.autodiscover() ...@@ -40,7 +41,12 @@ admin.autodiscover()
# NOTE 2: These same patterns are used for rest_framework's browseable API authentication links. # NOTE 2: These same patterns are used for rest_framework's browseable API authentication links.
AUTH_URLS = [url(r'^logout/$', LogoutView.as_view(), name='logout'), ] + auth_urlpatterns AUTH_URLS = [url(r'^logout/$', LogoutView.as_view(), name='logout'), ] + auth_urlpatterns
urlpatterns = AUTH_URLS + [ WELL_KNOWN_URLS = [
url(r'^.well-known/apple-developer-merchantid-domain-association$',
ApplePayMerchantDomainAssociationView.as_view(), name='apple_pay_domain_association'),
]
urlpatterns = AUTH_URLS + WELL_KNOWN_URLS + [
url(r'^admin/', include(admin.site.urls)), url(r'^admin/', include(admin.site.urls)),
url(r'^auto_auth/$', core_views.AutoAuth.as_view(), name='auto_auth'), url(r'^auto_auth/$', core_views.AutoAuth.as_view(), name='auto_auth'),
url(r'^api-auth/', include(AUTH_URLS, namespace='rest_framework')), url(r'^api-auth/', include(AUTH_URLS, namespace='rest_framework')),
......
...@@ -20,13 +20,13 @@ module.exports = function(config) { ...@@ -20,13 +20,13 @@ module.exports = function(config) {
// list of files / patterns to load in the browser // list of files / patterns to load in the browser
files: [ files: [
{pattern: 'ecommerce/static/vendor/**/*.js', included: false},
{pattern: 'ecommerce/static/bower_components/**/*.js', included: false}, {pattern: 'ecommerce/static/bower_components/**/*.js', included: false},
{pattern: 'ecommerce/static/js/**/*.js', included: false}, {pattern: 'ecommerce/static/js/**/*.js', included: false},
{pattern: 'ecommerce/static/templates/**/*.html', included: false}, {pattern: 'ecommerce/static/templates/**/*.html', included: false},
{pattern: 'ecommerce/static/js/test/fixtures/**/*.html', included: false, served: true, watched: true}, {pattern: 'ecommerce/static/js/test/fixtures/**/*.html', included: false, served: true, watched: true},
'ecommerce/static/js/config.js', 'ecommerce/static/js/config.js',
'ecommerce/static/js/test/spec-runner.js' 'ecommerce/static/js/test/spec-runner.js',
'node_modules/apple-pay-js-stubs/src/apple-pay-js-stubs.js'
], ],
// list of files to exclude // list of files to exclude
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
"requirejs": "2.3.3" "requirejs": "2.3.3"
}, },
"devDependencies": { "devDependencies": {
"apple-pay-js-stubs": "1.0.4",
"eslint": "3.19.0", "eslint": "3.19.0",
"eslint-config-edx-es5": "3.0.0", "eslint-config-edx-es5": "3.0.0",
"geckodriver": "1.6.1", "geckodriver": "1.6.1",
...@@ -27,6 +28,6 @@ ...@@ -27,6 +28,6 @@
"karma-requirejs": "1.1.0", "karma-requirejs": "1.1.0",
"karma-sinon": "1.0.5", "karma-sinon": "1.0.5",
"karma-spec-reporter": "0.0.26", "karma-spec-reporter": "0.0.26",
"sinon": "1.17.7" "sinon": "2.3.8"
} }
} }
...@@ -30,7 +30,7 @@ premailer==2.9.2 ...@@ -30,7 +30,7 @@ premailer==2.9.2
pycountry==17.1.8 pycountry==17.1.8
python-dateutil==2.4.2 python-dateutil==2.4.2
pytz==2016.10 pytz==2016.10
requests==2.17.3 requests==2.18.1
sailthru-client==2.2.3 sailthru-client==2.2.3
six==1.10.0 six==1.10.0
zeep==2.1.1 zeep==2.1.1
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