diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index b2ad8ccd66..1e2d665fa1 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -1,7 +1,23 @@ -### Implementation of support for the Cybersource Credit card processor -### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting -### Implementes interface as specified by __init__.py +""" +Implementation the CyberSource credit card processor. +IMPORTANT: CyberSource will deprecate this version of the API ("Hosted Order Page") in September 2014. +We are keeping this implementation in the code-base for now, but we should +eventually replace this module with the newer implementation (in `CyberSource2.py`) + +To enable this implementation, add the following to Django settings: + + CC_PROCESSOR_NAME = "CyberSource" + CC_PROCESSOR = { + "CyberSource": { + "SHARED_SECRET": "", + "MERCHANT_ID": "", + "SERIAL_NUMBER": "", + "PURCHASE_ENDPOINT": "" + } + } + +""" import time import hmac import binascii @@ -16,26 +32,11 @@ from django.utils.translation import ugettext as _ from edxmako.shortcuts import render_to_string from shoppingcart.models import Order from shoppingcart.processors.exceptions import * +from shoppingcart.processors.helpers import get_processor_config from microsite_configuration import microsite -def get_cybersource_config(): - """ - This method will return any microsite specific cybersource configuration, otherwise - we return the default configuration - """ - config_key = microsite.get_value('cybersource_config_key') - config = {} - if config_key: - # The microsite CyberSource configuration will be subkeys inside of the normal default - # CyberSource configuration - config = settings.CC_PROCESSOR['CyberSource']['microsites'][config_key] - else: - config = settings.CC_PROCESSOR['CyberSource'] - - return config - -def process_postpay_callback(params): +def process_postpay_callback(params, **kwargs): """ The top level call to this module, basically This function is handed the callback request after the customer has entered the CC info and clicked "buy" @@ -70,7 +71,7 @@ def processor_hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ - shared_secret = get_cybersource_config().get('SHARED_SECRET', '') + shared_secret = get_processor_config().get('SHARED_SECRET', '') hash_obj = hmac.new(shared_secret.encode('utf-8'), value.encode('utf-8'), sha1) return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want @@ -80,9 +81,9 @@ def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='order params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - merchant_id = get_cybersource_config().get('MERCHANT_ID', '') - order_page_version = get_cybersource_config().get('ORDERPAGE_VERSION', '7') - serial_number = get_cybersource_config().get('SERIAL_NUMBER', '') + merchant_id = get_processor_config().get('MERCHANT_ID', '') + order_page_version = get_processor_config().get('ORDERPAGE_VERSION', '7') + serial_number = get_processor_config().get('SERIAL_NUMBER', '') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time() * 1000) @@ -115,7 +116,7 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si raise CCProcessorSignatureException() -def render_purchase_form_html(cart): +def render_purchase_form_html(cart, **kwargs): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ @@ -124,9 +125,11 @@ def render_purchase_form_html(cart): 'params': get_signed_purchase_params(cart), }) -def get_signed_purchase_params(cart): + +def get_signed_purchase_params(cart, **kwargs): return sign(get_purchase_params(cart)) + def get_purchase_params(cart): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) @@ -139,8 +142,10 @@ def get_purchase_params(cart): return params + def get_purchase_endpoint(): - return get_cybersource_config().get('PURCHASE_ENDPOINT', '') + return get_processor_config().get('PURCHASE_ENDPOINT', '') + def payment_accepted(params): """ diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource2.py b/lms/djangoapps/shoppingcart/processors/CyberSource2.py index 9293831d79..2e2734ac4d 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource2.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource2.py @@ -1,123 +1,254 @@ -### Implementation of support for the Cybersource Credit card processor using the new -### Secure Acceptance API. The previous Hosted Order Page API is being deprecated as of 9/14 -### It is mostly the same as the CyberSource.py file, but we have a new file so that we can -### maintain some backwards-compatibility in case of a need to quickly roll back (i.e. -### configuration change rather than code rollback ) +""" +Implementation of the CyberSource credit card processor using the newer "Secure Acceptance API". +The previous Hosted Order Page API is being deprecated as of 9/14. -### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting -### Implementes interface as specified by __init__.py +For now, we're keeping the older implementation in the code-base so we can +quickly roll-back by updating the configuration. Eventually, we should replace +the original implementation with this version. + +To enable this implementation, add the following Django settings: + + CC_PROCESSOR_NAME = "CyberSource2" + CC_PROCESSOR = { + "CyberSource2": { + "SECRET_KEY": "", + "ACCESS_KEY": "", + "PROFILE_ID": "", + "PURCHASE_ENDPOINT": "" + } + } + +""" import hmac import binascii import re import json import uuid +from textwrap import dedent from datetime import datetime from collections import OrderedDict, defaultdict from decimal import Decimal, InvalidOperation from hashlib import sha256 -from textwrap import dedent from django.conf import settings from django.utils.translation import ugettext as _ from edxmako.shortcuts import render_to_string from shoppingcart.models import Order from shoppingcart.processors.exceptions import * +from shoppingcart.processors.helpers import get_processor_config from microsite_configuration import microsite -from django.core.urlresolvers import reverse - - -def get_cybersource_config(): - """ - This method will return any microsite specific cybersource configuration, otherwise - we return the default configuration - """ - config_key = microsite.get_value('cybersource_config_key') - config = {} - if config_key: - # The microsite CyberSource configuration will be subkeys inside of the normal default - # CyberSource configuration - config = settings.CC_PROCESSOR['CyberSource2']['microsites'][config_key] - else: - config = settings.CC_PROCESSOR['CyberSource2'] - - return config def process_postpay_callback(params): """ - The top level call to this module, basically - This function is handed the callback request after the customer has entered the CC info and clicked "buy" - on the external Hosted Order Page. - It is expected to verify the callback and determine if the payment was successful. - It returns {'success':bool, 'order':Order, 'error_html':str} - If successful this function must have the side effect of marking the order purchased and calling the - purchased_callbacks of the cart items. - If unsuccessful this function should not have those side effects but should try to figure out why and - return a helpful-enough error message in error_html. + Handle a response from the payment processor. + + Concrete implementations should: + 1) Verify the parameters and determine if the payment was successful. + 2) If successful, mark the order as purchased and call `purchased_callbacks` of the cart items. + 3) If unsuccessful, try to figure out why and generate a helpful error message. + 4) Return a dictionary of the form: + {'success': bool, 'order': Order, 'error_html': str} + + Args: + params (dict): Dictionary of parameters received from the payment processor. + + Keyword Args: + Can be used to provide additional information to concrete implementations. + + Returns: + dict + """ try: - result = payment_accepted(params) + valid_params = verify_signatures(params) + result = _payment_accepted( + valid_params['req_reference_number'], + valid_params['auth_amount'], + valid_params['req_currency'], + valid_params['decision'] + ) if result['accepted']: - # SUCCESS CASE first, rest are some sort of oddity - record_purchase(params, result['order']) - return {'success': True, - 'order': result['order'], - 'error_html': ''} + _record_purchase(params, result['order']) + return { + 'success': True, + 'order': result['order'], + 'error_html': '' + } else: - return {'success': False, - 'order': result['order'], - 'error_html': get_processor_decline_html(params)} + return { + 'success': False, + 'order': result['order'], + 'error_html': _get_processor_decline_html(params) + } except CCProcessorException as error: - return {'success': False, - 'order': None, # due to exception we may not have the order - 'error_html': get_processor_exception_html(error)} + return { + 'success': False, + 'order': None, # due to exception we may not have the order + 'error_html': _get_processor_exception_html(error) + } def processor_hash(value): """ - Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + Calculate the base64-encoded, SHA-256 hash used by CyberSource. + + Args: + value (string): The value to encode. + + Returns: + string + """ - secret_key = get_cybersource_config().get('SECRET_KEY', '') + secret_key = get_processor_config().get('SECRET_KEY', '') hash_obj = hmac.new(secret_key, value, sha256) return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want -def sign(params, signed_fields_key='signed_field_names', full_sig_key='signature'): +def verify_signatures(params): """ - params needs to be an ordered dict, b/c cybersource documentation states that order is important. - Reverse engineered from PHP version provided by cybersource + Use the signature we receive in the POST back from CyberSource to verify + the identity of the sender (CyberSource) and that the contents of the message + have not been tampered with. + + Args: + params (dictionary): The POST parameters we received from CyberSource. + + Returns: + dict: Contains the parameters we will use elsewhere, converted to the + appropriate types + + Raises: + CCProcessorSignatureException: The calculated signature does not match + the signature we received. + + CCProcessorDataException: The parameters we received from CyberSource were not valid + (missing keys, wrong types) + + """ + # Validate the signature to ensure that the message is from CyberSource + # and has not been tampered with. + signed_fields = params.get('signed_field_names', '').split(',') + data = u",".join([u"{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + returned_sig = params.get('signature', '') + if processor_hash(data) != returned_sig: + raise CCProcessorSignatureException() + + # Validate that we have the paramters we expect and can convert them + # to the appropriate types. + # Usually validating the signature is sufficient to validate that these + # fields exist, but since we're relying on CyberSource to tell us + # which fields they included in the signature, we need to be careful. + valid_params = {} + required_params = [ + ('req_reference_number', int), + ('req_currency', str), + ('decision', str), + ('auth_amount', Decimal), + ] + for key, key_type in required_params: + if key not in params: + raise CCProcessorDataException( + _( + u"The payment processor did not return a required parameter: {parameter}" + ).format(parameter=key) + ) + try: + valid_params[key] = key_type(params[key]) + except (ValueError, TypeError, InvalidOperation): + raise CCProcessorDataException( + _( + u"The payment processor returned a badly-typed value {value} for parameter {parameter}." + ).format(value=params[key], parameter=key) + ) + + return valid_params + + +def sign(params): + """ + Sign the parameters dictionary so CyberSource can validate our identity. + + The params dict should contain a key 'signed_field_names' that is a comma-separated + list of keys in the dictionary. The order of this list is important! + + Args: + params (dict): Dictionary of parameters; must include a 'signed_field_names' key + + Returns: + dict: The same parameters dict, with a 'signature' key calculated from the other values. + """ fields = u",".join(params.keys()) - params[signed_fields_key] = fields + params['signed_field_names'] = fields - signed_fields = params.get(signed_fields_key, '').split(',') + signed_fields = params.get('signed_field_names', '').split(',') values = u",".join([u"{0}={1}".format(i, params.get(i, '')) for i in signed_fields]) - params[full_sig_key] = processor_hash(values) - params[signed_fields_key] = fields + params['signature'] = processor_hash(values) + params['signed_field_names'] = fields return params -def render_purchase_form_html(cart): +def render_purchase_form_html(cart, callback_url=None): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource + + Args: + cart (Order): The order model representing items in the user's cart. + + Keyword Args: + callback_url (unicode): The URL that CyberSource should POST to when the user + completes a purchase. If not provided, then CyberSource will use + the URL provided by the administrator of the account + (CyberSource config, not LMS config). + + Returns: + unicode: The rendered HTML form. + """ return render_to_string('shoppingcart/cybersource_form.html', { 'action': get_purchase_endpoint(), - 'params': get_signed_purchase_params(cart), + 'params': get_signed_purchase_params(cart, callback_url=callback_url), }) -def get_signed_purchase_params(cart): +def get_signed_purchase_params(cart, callback_url=None): """ This method will return a digitally signed set of CyberSource parameters + + Args: + cart (Order): The order model representing items in the user's cart. + + Keyword Args: + callback_url (unicode): The URL that CyberSource should POST to when the user + completes a purchase. If not provided, then CyberSource will use + the URL provided by the administrator of the account + (CyberSource config, not LMS config). + + Returns: + dict + """ - return sign(get_purchase_params(cart)) + return sign(get_purchase_params(cart, callback_url=callback_url)) -def get_purchase_params(cart): +def get_purchase_params(cart, callback_url=None): """ This method will build out a dictionary of parameters needed by CyberSource to complete the transaction + + Args: + cart (Order): The order model representing items in the user's cart. + + Keyword Args: + callback_url (unicode): The URL that CyberSource should POST to when the user + completes a purchase. If not provided, then CyberSource will use + the URL provided by the administrator of the account + (CyberSource config, not LMS config). + + Returns: + dict + """ total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) @@ -127,8 +258,8 @@ def get_purchase_params(cart): params['currency'] = cart.currency params['orderNumber'] = "OrderId: {0:d}".format(cart.id) - params['access_key'] = get_cybersource_config().get('ACCESS_KEY', '') - params['profile_id'] = get_cybersource_config().get('PROFILE_ID', '') + params['access_key'] = get_processor_config().get('ACCESS_KEY', '') + params['profile_id'] = get_processor_config().get('PROFILE_ID', '') params['reference_number'] = cart.id params['transaction_type'] = 'sale' @@ -136,91 +267,99 @@ def get_purchase_params(cart): params['signed_date_time'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') params['signed_field_names'] = 'access_key,profile_id,amount,currency,transaction_type,reference_number,signed_date_time,locale,transaction_uuid,signed_field_names,unsigned_field_names,orderNumber' params['unsigned_field_names'] = '' - params['transaction_uuid'] = uuid.uuid4() + params['transaction_uuid'] = uuid.uuid4().hex params['payment_method'] = 'card' - if hasattr(cart, 'context') and 'request_domain' in cart.context: - params['override_custom_receipt_page'] = '{0}{1}'.format( - cart.context['request_domain'], - reverse('shoppingcart.views.postpay_callback') - ) + if callback_url is not None: + params['override_custom_receipt_page'] = callback_url return params def get_purchase_endpoint(): """ - Helper function to return the CyberSource endpoint configuration - """ - return get_cybersource_config().get('PURCHASE_ENDPOINT', '') + Return the URL of the payment end-point for CyberSource. - -def payment_accepted(params): - """ - Check that cybersource has accepted the payment - params: a dictionary of POST parameters returned by CyberSource in their post-payment callback - - returns: true if the payment was correctly accepted, for the right amount - false if the payment was not accepted - - raises: CCProcessorDataException if the returned message did not provide required parameters - CCProcessorWrongAmountException if the amount charged is different than the order amount + Returns: + unicode """ - #make sure required keys are present and convert their values to the right type - valid_params = {} - for key, key_type in [('req_reference_number', int), - ('req_currency', str), - ('decision', str)]: - if key not in params: - raise CCProcessorDataException( - _("The payment processor did not return a required parameter: {0}".format(key)) - ) - try: - valid_params[key] = key_type(params[key]) - except ValueError: - raise CCProcessorDataException( - _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) - ) + return get_processor_config().get('PURCHASE_ENDPOINT', '') + +def _payment_accepted(order_id, auth_amount, currency, decision): + """ + Check that CyberSource has accepted the payment. + + Args: + order_num (int): The ID of the order associated with this payment. + auth_amount (Decimal): The amount the user paid using CyberSource. + currency (str): The currency code of the payment. + decision (str): "ACCEPT" if the payment was accepted. + + Returns: + dictionary of the form: + { + 'accepted': bool, + 'amnt_charged': int, + 'currency': string, + 'order': Order + } + + Raises: + CCProcessorDataException: The order does not exist. + CCProcessorWrongAmountException: The user did not pay the correct amount. + + """ try: - order = Order.objects.get(id=valid_params['req_reference_number']) + order = Order.objects.get(id=order_id) except Order.DoesNotExist: raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) - if valid_params['decision'] == 'ACCEPT': - try: - # Moved reading of charged_amount here from the valid_params loop above because - # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter - charged_amt = Decimal(params['auth_amount']) - except InvalidOperation: - raise CCProcessorDataException( - _("The payment processor returned a badly-typed value {0} for param {1}.".format( - params['auth_amount'], 'auth_amount')) - ) - - if charged_amt == order.total_cost and valid_params['req_currency'] == order.currency: - return {'accepted': True, - 'amt_charged': charged_amt, - 'currency': valid_params['req_currency'], - 'order': order} + if decision == 'ACCEPT': + if auth_amount == order.total_cost and currency == order.currency: + return { + 'accepted': True, + 'amt_charged': auth_amount, + 'currency': currency, + 'order': order + } else: raise CCProcessorWrongAmountException( - _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}." - .format(charged_amt, valid_params['req_currency'], - order.total_cost, order.currency)) + _( + u"The amount charged by the processor {charged_amount} {charged_amount_currency} is different " + u"than the total cost of the order {total_cost} {total_cost_currency}." + ).format( + charged_amount=auth_amount, + charged_amount_currency=currency, + total_cost=order.total_cost, + total_cost_currency=order.currency + ) ) else: - return {'accepted': False, - 'amt_charged': 0, - 'currency': 'usd', - 'order': order} + return { + 'accepted': False, + 'amt_charged': 0, + 'currency': 'usd', + 'order': order + } -def record_purchase(params, order): +def _record_purchase(params, order): """ Record the purchase and run purchased_callbacks + + Args: + params (dict): The parameters we received from CyberSource. + order (Order): The order associated with this payment. + + Returns: + None + """ + # Usually, the credit card number will have the form "xxxxxxxx1234" + # Parse the string to retrieve the digits. + # If we can't find any digits, use placeholder values instead. ccnum_str = params.get('req_card_number', '') mm = re.search("\d", ccnum_str) if mm: @@ -228,6 +367,7 @@ def record_purchase(params, order): else: ccnum = "####" + # Mark the order as purchased and store the billing information order.purchase( first=params.get('req_bill_to_forename', ''), last=params.get('req_bill_to_surname', ''), @@ -243,57 +383,96 @@ def record_purchase(params, order): ) -def get_processor_decline_html(params): - """Have to parse through the error codes to return a helpful message""" +def _get_processor_decline_html(params): + """ + Return HTML indicating that the user's payment was declined. + + Args: + params (dict): Parameters we received from CyberSource. + + Returns: + unicode: The rendered HTML. + + """ payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) - - msg = dedent(_( - """ -

- Sorry! Our payment processor did not accept your payment. - The decision they returned was {decision}, - and the reason was {reason_code}:{reason_msg}. - You were not charged. Please try a different form of payment. - Contact us with payment-related questions at {email}. -

- """)) - - return msg.format( - decision=params['decision'], - reason_code=params['reason_code'], - reason_msg=REASONCODE_MAP[params['reason_code']], - email=payment_support_email + return _format_error_html( + _( + "Sorry! Our payment processor did not accept your payment. " + "The decision they returned was {decision}, " + "and the reason was {reason}. " + "You were not charged. Please try a different form of payment. " + "Contact us with payment-related questions at {email}." + ).format( + decision='{decision}'.format(decision=params['decision']), + reason='{reason_code}:{reason_msg}'.format( + reason_code=params['reason_code'], + reason_msg=REASONCODE_MAP.get(params['reason_code']) + ), + email=payment_support_email + ) ) -def get_processor_exception_html(exception): - """Return error HTML associated with exception""" +def _get_processor_exception_html(exception): + """ + Return HTML indicating that an error occurred. + Args: + exception (CCProcessorException): The exception that occurred. + + Returns: + unicode: The rendered HTML. + + """ payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) if isinstance(exception, CCProcessorDataException): - msg = dedent(_( - """ -

- Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! - We apologize that we cannot verify whether the charge went through and take further action on your order. - The specific error message is: {msg}. - Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. -

- """.format(msg=exception.message, email=payment_support_email))) - return msg + return _format_error_html( + _( + u"Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! " + u"We apologize that we cannot verify whether the charge went through and take further action on your order. " + u"The specific error message is: {msg} " + u"Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}." + ).format( + msg=u'{msg}'.format(msg=exception.message), + email=payment_support_email + ) + ) elif isinstance(exception, CCProcessorWrongAmountException): - msg = dedent(_( - """ -

- Sorry! Due to an error your purchase was charged for a different amount than the order total! - The specific error message is: {msg}. - Your credit card has probably been charged. Contact us with payment-specific questions at {email}. -

- """.format(msg=exception.message, email=payment_support_email))) - return msg + return _format_error_html( + _( + u"Sorry! Due to an error your purchase was charged for a different amount than the order total! " + u"The specific error message is: {msg}. " + u"Your credit card has probably been charged. Contact us with payment-specific questions at {email}." + ).format( + msg=u'{msg}'.format(msg=exception.message), + email=payment_support_email + ) + ) + elif isinstance(exception, CCProcessorSignatureException): + return _format_error_html( + _( + u"Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are " + u"unable to validate that the message actually came from the payment processor. " + u"The specific error message is: {msg}. " + u"We apologize that we cannot verify whether the charge went through and take further action on your order. " + u"Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}." + ).format( + msg=u'{msg}'.format(msg=exception.message), + email=payment_support_email + ) + ) + else: + return _format_error_html( + _( + u"Sorry! Your payment could not be processed because an unexpected exception occurred. " + u"Please contact us at {email} for assistance." + ).format(email=payment_support_email) + ) - # fallthrough case, which basically never happens - return '

EXCEPTION!

' + +def _format_error_html(msg): + """ Format an HTML error message """ + return '

{msg}

'.format(msg=msg) CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN") diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index 4051d4c3ec..ff18608576 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -1,33 +1,91 @@ +""" +Public API for payment processor implementations. +The specific implementation is determined at runtime using Django settings: + + CC_PROCESSOR_NAME: The name of the Python module (in `shoppingcart.processors`) to use. + + CC_PROCESSOR: Dictionary of configuration options for specific processor implementations, + keyed to processor names. + +""" + from django.conf import settings -### Now code that determines, using settings, which actual processor implementation we're using. -processor_name = settings.CC_PROCESSOR.keys()[0] -module = __import__('shoppingcart.processors.' + processor_name, - fromlist=['render_purchase_form_html' - 'process_postpay_callback', - ]) + +# Import the processor implementation, using `CC_PROCESSOR_NAME` +# as the name of the Python module in `shoppingcart.processors` +PROCESSOR_MODULE = __import__( + 'shoppingcart.processors.' + settings.CC_PROCESSOR_NAME, + fromlist=[ + 'render_purchase_form_html', + 'process_postpay_callback', + 'get_purchase_endpoint', + 'get_signed_purchase_params', + ] +) -def render_purchase_form_html(*args, **kwargs): +def render_purchase_form_html(cart, **kwargs): """ - The top level call to this module to begin the purchase. - Given a shopping cart, - Renders the HTML form for display on user's browser, which POSTS to Hosted Processors - Returns the HTML as a string + Render an HTML form with POSTs to the hosted payment processor. + + Args: + cart (Order): The order model representing items in the user's cart. + + Returns: + unicode: the rendered HTML form + """ - return module.render_purchase_form_html(*args, **kwargs) + return PROCESSOR_MODULE.render_purchase_form_html(cart, **kwargs) -def process_postpay_callback(*args, **kwargs): +def process_postpay_callback(params, **kwargs): """ - The top level call to this module after the purchase. - This function is handed the callback request after the customer has entered the CC info and clicked "buy" - on the external payment page. - It is expected to verify the callback and determine if the payment was successful. - It returns {'success':bool, 'order':Order, 'error_html':str} - If successful this function must have the side effect of marking the order purchased and calling the - purchased_callbacks of the cart items. - If unsuccessful this function should not have those side effects but should try to figure out why and - return a helpful-enough error message in error_html. + Handle a response from the payment processor. + + Concrete implementations should: + 1) Verify the parameters and determine if the payment was successful. + 2) If successful, mark the order as purchased and call `purchased_callbacks` of the cart items. + 3) If unsuccessful, try to figure out why and generate a helpful error message. + 4) Return a dictionary of the form: + {'success': bool, 'order': Order, 'error_html': str} + + Args: + params (dict): Dictionary of parameters received from the payment processor. + + Keyword Args: + Can be used to provide additional information to concrete implementations. + + Returns: + dict + """ - return module.process_postpay_callback(*args, **kwargs) + return PROCESSOR_MODULE.process_postpay_callback(params, **kwargs) + + +def get_purchase_endpoint(): + """ + Return the URL of the current payment processor's endpoint. + + Returns: + unicode + + """ + return PROCESSOR_MODULE.get_purchase_endpoint() + + +def get_signed_purchase_params(cart, **kwargs): + """ + Return the parameters to send to the current payment processor. + + Args: + cart (Order): The order model representing items in the user's cart. + + Keyword Args: + Can be used to provide additional information to concrete implementations. + + Returns: + dict + + """ + return PROCESSOR_MODULE.get_signed_purchase_params(cart, **kwargs) diff --git a/lms/djangoapps/shoppingcart/processors/helpers.py b/lms/djangoapps/shoppingcart/processors/helpers.py new file mode 100644 index 0000000000..69461df508 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/helpers.py @@ -0,0 +1,31 @@ +""" +Helper methods for credit card processing modules. +These methods should be shared among all processor implementations, +but should NOT be imported by modules outside this package. +""" +from django.conf import settings +from microsite_configuration import microsite + + +def get_processor_config(): + """ + Return a dictionary of configuration settings for the active credit card processor. + If we're in a microsite and overrides are available, return those instead. + + Returns: + dict + + """ + # Retrieve the configuration settings for the active credit card processor + config = settings.CC_PROCESSOR.get( + settings.CC_PROCESSOR_NAME, {} + ) + + # Check whether we're in a microsite that overrides our configuration + # If so, find the microsite-specific configuration in the 'microsites' + # sub-key of the normal processor configuration. + config_key = microsite.get_value('cybersource_config_key') + if config_key: + config = config['microsites'][config_key] + + return config diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index 8d1c9aeb51..f8120f911c 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -7,13 +7,29 @@ from django.test.utils import override_settings from django.conf import settings from student.tests.factories import UserFactory from shoppingcart.models import Order, OrderItem -from shoppingcart.processors.CyberSource import * -from shoppingcart.processors.exceptions import * +from shoppingcart.processors.helpers import get_processor_config +from shoppingcart.processors.exceptions import ( + CCProcessorException, + CCProcessorSignatureException, + CCProcessorDataException, + CCProcessorWrongAmountException +) +from shoppingcart.processors.CyberSource import ( + render_purchase_form_html, + process_postpay_callback, + processor_hash, + verify_signatures, + sign, + REASONCODE_MAP, + record_purchase, + get_processor_decline_html, + get_processor_exception_html, + payment_accepted, +) from mock import patch, Mock -from microsite_configuration import microsite -import mock +TEST_CC_PROCESSOR_NAME = "CyberSource" TEST_CC_PROCESSOR = { 'CyberSource': { 'SHARED_SECRET': 'secret', @@ -43,24 +59,25 @@ def fakemicrosite(name, default=None): else: return None -@override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) -class CyberSourceTests(TestCase): - def setUp(self): - pass +@override_settings( + CC_PROCESSOR_NAME=TEST_CC_PROCESSOR_NAME, + CC_PROCESSOR=TEST_CC_PROCESSOR +) +class CyberSourceTests(TestCase): def test_override_settings(self): self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') def test_microsite_no_override_settings(self): - self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test') - self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret') + self.assertEqual(get_processor_config()['MERCHANT_ID'], 'edx_test') + self.assertEqual(get_processor_config()['SHARED_SECRET'], 'secret') - @mock.patch("microsite_configuration.microsite.get_value", fakemicrosite) + @patch("microsite_configuration.microsite.get_value", fakemicrosite) def test_microsite_override_settings(self): - self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test_override') - self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret_override') + self.assertEqual(get_processor_config()['MERCHANT_ID'], 'edx_test_override') + self.assertEqual(get_processor_config()['SHARED_SECRET'], 'secret_override') def test_hash(self): """ @@ -258,7 +275,7 @@ class CyberSourceTests(TestCase): order1 = Order.get_cart_for_user(student1) item1 = OrderItem(order=order1, user=student1, unit_cost=1.0, line_cost=1.0) item1.save() - html = render_purchase_form_html(order1) + render_purchase_form_html(order1) ((template, context), render_kwargs) = render.call_args self.assertEqual(template, 'shoppingcart/cybersource_form.html') diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py new file mode 100644 index 0000000000..9178d51807 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +""" +Tests for the newer CyberSource API implementation. +""" +from mock import patch +from django.test import TestCase +import ddt + +from student.tests.factories import UserFactory +from shoppingcart.models import Order, OrderItem +from shoppingcart.processors.CyberSource2 import ( + processor_hash, + process_postpay_callback, + render_purchase_form_html, + get_signed_purchase_params +) + + +@ddt.ddt +class CyberSource2Test(TestCase): + """ + Test the CyberSource API implementation. As much as possible, + this test case should use ONLY the public processor interface + (defined in shoppingcart.processors.__init__.py). + + Some of the tests in this suite rely on Django settings + to be configured a certain way. + + """ + + COST = "10.00" + CALLBACK_URL = "/test_callback_url" + + def setUp(self): + """ Create a user and an order. """ + self.user = UserFactory() + self.order = Order.get_cart_for_user(self.user) + self.order_item = OrderItem.objects.create( + order=self.order, + user=self.user, + unit_cost=self.COST, + line_cost=self.COST + ) + + def test_render_purchase_form_html(self): + # Verify that the HTML form renders with the payment URL specified + # in the test settings. + # This does NOT test that all the form parameters are correct; + # we verify that by testing `get_signed_purchase_params()` directly. + html = render_purchase_form_html(self.order, callback_url=self.CALLBACK_URL) + self.assertIn('
', html) + self.assertIn('transaction_uuid', html) + self.assertIn('signature', html) + self.assertIn(self.CALLBACK_URL, html) + + def test_get_signed_purchase_params(self): + params = get_signed_purchase_params(self.order, callback_url=self.CALLBACK_URL) + + # Check the callback URL override + self.assertEqual(params['override_custom_receipt_page'], self.CALLBACK_URL) + + # Parameters determined by the order model + self.assertEqual(params['amount'], '10.00') + self.assertEqual(params['currency'], 'usd') + self.assertEqual(params['orderNumber'], 'OrderId: {order_id}'.format(order_id=self.order.id)) + self.assertEqual(params['reference_number'], self.order.id) + + # Parameters determined by the Django (test) settings + self.assertEqual(params['access_key'], '0123456789012345678901') + self.assertEqual(params['profile_id'], 'edx') + + # Some fields will change depending on when the test runs, + # so we just check that they're set to a non-empty string + self.assertGreater(len(params['signed_date_time']), 0) + self.assertGreater(len(params['transaction_uuid']), 0) + + # Constant parameters + self.assertEqual(params['transaction_type'], 'sale') + self.assertEqual(params['locale'], 'en') + self.assertEqual(params['payment_method'], 'card') + self.assertEqual( + params['signed_field_names'], + ",".join([ + 'amount', + 'currency', + 'orderNumber', + 'access_key', + 'profile_id', + 'reference_number', + 'transaction_type', + 'locale', + 'signed_date_time', + 'signed_field_names', + 'unsigned_field_names', + 'transaction_uuid', + 'payment_method', + 'override_custom_receipt_page' + ]) + ) + self.assertEqual(params['unsigned_field_names'], '') + + # Check the signature + self.assertEqual(params['signature'], self._signature(params)) + + # We patch the purchased callback because + # (a) we're using the OrderItem base class, which doesn't implement this method, and + # (b) we want to verify that the method gets called on success. + @patch.object(OrderItem, 'purchased_callback') + def test_process_payment_success(self, purchased_callback): + # Simulate a callback from CyberSource indicating that payment was successful + params = self._signed_callback_params(self.order.id, self.COST, self.COST) + result = process_postpay_callback(params) + + # Expect that we processed the payment successfully + self.assertTrue( + result['success'], + msg="Payment was not successful: {error}".format(error=result.get('error_html')) + ) + self.assertEqual(result['error_html'], '') + + # Expect that the item's purchased callback was invoked + purchased_callback.assert_called_with() + + # Expect that the order has been marked as purchased + self.assertEqual(result['order'].status, 'purchased') + + def test_process_payment_rejected(self): + # Simulate a callback from CyberSource indicating that the payment was rejected + params = self._signed_callback_params(self.order.id, self.COST, self.COST, accepted=False) + result = process_postpay_callback(params) + + # Expect that we get an error message + self.assertFalse(result['success']) + self.assertIn(u"did not accept your payment", result['error_html']) + + def test_process_payment_invalid_signature(self): + # Simulate a callback from CyberSource indicating that the payment was rejected + params = self._signed_callback_params(self.order.id, self.COST, self.COST, signature="invalid!") + result = process_postpay_callback(params) + + # Expect that we get an error message + self.assertFalse(result['success']) + self.assertIn(u"corrupted message regarding your charge", result['error_html']) + + def test_process_payment_invalid_order(self): + # Use an invalid order ID + params = self._signed_callback_params("98272", self.COST, self.COST) + result = process_postpay_callback(params) + + # Expect an error + self.assertFalse(result['success']) + self.assertIn(u"inconsistent data", result['error_html']) + + def test_process_invalid_payment_amount(self): + # Change the payment amount (no longer matches the database order record) + params = self._signed_callback_params(self.order.id, "145.00", "145.00") + result = process_postpay_callback(params) + + # Expect an error + self.assertFalse(result['success']) + self.assertIn(u"different amount than the order total", result['error_html']) + + def test_process_amount_paid_not_decimal(self): + # Change the payment amount to a non-decimal + params = self._signed_callback_params(self.order.id, self.COST, "abcd") + result = process_postpay_callback(params) + + # Expect an error + self.assertFalse(result['success']) + self.assertIn(u"badly-typed value", result['error_html']) + + @patch.object(OrderItem, 'purchased_callback') + def test_process_no_credit_card_digits(self, callback): + # Use a credit card number with no digits provided + params = self._signed_callback_params( + self.order.id, self.COST, self.COST, + card_number='nodigits' + ) + result = process_postpay_callback(params) + + # Expect that we processed the payment successfully + self.assertTrue( + result['success'], + msg="Payment was not successful: {error}".format(error=result.get('error_html')) + ) + self.assertEqual(result['error_html'], '') + + # Expect that the order has placeholders for the missing credit card digits + self.assertEqual(result['order'].bill_to_ccnum, '####') + + @ddt.data('req_reference_number', 'req_currency', 'decision', 'auth_amount') + def test_process_missing_parameters(self, missing_param): + # Remove a required parameter + params = self._signed_callback_params(self.order.id, self.COST, self.COST) + del params[missing_param] + + # Recalculate the signature with no signed fields so we can get past + # signature validation. + params['signed_field_names'] = 'reason_code,message' + params['signature'] = self._signature(params) + + result = process_postpay_callback(params) + + # Expect an error + self.assertFalse(result['success']) + self.assertIn(u"did not return a required parameter", result['error_html']) + + def _signed_callback_params( + self, order_id, order_amount, paid_amount, + accepted=True, signature=None, card_number='xxxxxxxxxxxx1111' + ): + """ + Construct parameters that could be returned from CyberSource + to our payment callback. + + Some values can be overridden to simulate different test scenarios, + but most are fake values captured from interactions with + a CyberSource test account. + + Args: + order_id (string or int): The ID of the `Order` model. + order_amount (string): The cost of the order. + paid_amount (string): The amount the user paid using CyberSource. + + Keyword Args: + + accepted (bool): Whether the payment was accepted or rejected. + signature (string): If provided, use this value instead of calculating the signature. + card_numer (string): If provided, use this value instead of the default credit card number. + + Returns: + dict + + """ + # Parameters sent from CyberSource to our callback implementation + # These were captured from the CC test server. + params = { + # Parameters that change based on the test + "decision": "ACCEPT" if accepted else "REJECT", + "req_reference_number": str(order_id), + "req_amount": order_amount, + "auth_amount": paid_amount, + "req_card_number": card_number, + + # Stub values + "utf8": u"✓", + "req_bill_to_address_country": "US", + "auth_avs_code": "X", + "req_card_expiry_date": "01-2018", + "bill_trans_ref_no": "85080648RYI23S6I", + "req_bill_to_address_state": "MA", + "signed_field_names": ",".join([ + "transaction_id", + "decision", + "req_access_key", + "req_profile_id", + "req_transaction_uuid", + "req_transaction_type", + "req_reference_number", + "req_amount", + "req_currency", + "req_locale", + "req_payment_method", + "req_override_custom_receipt_page", + "req_bill_to_forename", + "req_bill_to_surname", + "req_bill_to_email", + "req_bill_to_address_line1", + "req_bill_to_address_city", + "req_bill_to_address_state", + "req_bill_to_address_country", + "req_bill_to_address_postal_code", + "req_card_number", + "req_card_type", + "req_card_expiry_date", + "message", + "reason_code", + "auth_avs_code", + "auth_avs_code_raw", + "auth_response", + "auth_amount", + "auth_code", + "auth_trans_ref_no", + "auth_time", + "bill_trans_ref_no", + "signed_field_names", + "signed_date_time" + ]), + "req_payment_method": "card", + "req_transaction_type": "sale", + "auth_code": "888888", + "req_locale": "en", + "reason_code": "100", + "req_bill_to_address_postal_code": "02139", + "req_bill_to_address_line1": "123 Fake Street", + "req_card_type": "001", + "req_bill_to_address_city": "Boston", + "signed_date_time": "2014-08-18T14:07:10Z", + "req_currency": "usd", + "auth_avs_code_raw": "I1", + "transaction_id": "4083708299660176195663", + "auth_time": "2014-08-18T140710Z", + "message": "Request was processed successfully.", + "auth_response": "100", + "req_profile_id": "0000001", + "req_transaction_uuid": "ddd9935b82dd403f9aa4ba6ecf021b1f", + "auth_trans_ref_no": "85080648RYI23S6I", + "req_bill_to_surname": "Doe", + "req_bill_to_forename": "John", + "req_bill_to_email": "john@example.com", + "req_override_custom_receipt_page": "http://localhost:8000/shoppingcart/postpay_callback/", + "req_access_key": "abcd12345", + } + + # Calculate the signature + params['signature'] = signature if signature is not None else self._signature(params) + return params + + def _signature(self, params): + """ + Calculate the signature from a dictionary of params. + + NOTE: This method uses the processor's hashing method. That method + is a thin wrapper of standard library calls, and it seemed overly complex + to rewrite that code in the test suite. + + Args: + params (dict): Dictionary with a key 'signed_field_names', + which is a comma-separated list of keys in the dictionary + to include in the signature. + + Returns: + string + + """ + return processor_hash( + ",".join([ + "{0}={1}".format(signed_field, params[signed_field]) + for signed_field + in params['signed_field_names'].split(",") + ]) + ) diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py index 0de87410ef..3202b944f0 100644 --- a/lms/djangoapps/shoppingcart/tests/payment_fake.py +++ b/lms/djangoapps/shoppingcart/tests/payment_fake.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Fake payment page for use in acceptance tests. This view is enabled in the URLs by the feature flag `ENABLE_PAYMENT_FAKE`. @@ -21,7 +22,7 @@ from edxmako.shortcuts import render_to_response # We use the same hashing function as the software under test, # because it mainly uses standard libraries, and I want # to avoid duplicating that code. -from shoppingcart.processors.CyberSource import processor_hash +from shoppingcart.processors.CyberSource2 import processor_hash class PaymentFakeView(View): @@ -51,7 +52,7 @@ class PaymentFakeView(View): * Triggers a POST to `postpay_callback()` on submit. * Has hidden fields for all the data CyberSource sends to the callback. - - Most of this data is duplicated from the request POST params (e.g. `amount` and `course_id`) + - Most of this data is duplicated from the request POST params (e.g. `amount`) - Other params contain fake data (always the same user name and address. - Still other params are calculated (signatures) @@ -63,7 +64,7 @@ class PaymentFakeView(View): served by the shopping cart app. """ if self._is_signature_valid(request.POST): - return self._payment_page_response(request.POST, '/shoppingcart/postpay_callback/') + return self._payment_page_response(request.POST) else: return render_to_response('shoppingcart/test/fake_payment_error.html') @@ -91,22 +92,17 @@ class PaymentFakeView(View): Return a bool indicating whether the client sent us a valid signature in the payment page request. """ - - # Calculate the fields signature - fields_sig = processor_hash(post_params.get('orderPage_signedFields')) - # Retrieve the list of signed fields - signed_fields = post_params.get('orderPage_signedFields').split(',') + signed_fields = post_params.get('signed_field_names').split(',') # Calculate the public signature hash_val = ",".join([ "{0}={1}".format(key, post_params[key]) for key in signed_fields - ]) + ",signedFieldsPublicSignature={0}".format(fields_sig) - + ]) public_sig = processor_hash(hash_val) - return public_sig == post_params.get('orderPage_signaturePublic') + return (public_sig == post_params.get('signature')) @classmethod def response_post_params(cls, post_params): @@ -117,88 +113,76 @@ class PaymentFakeView(View): # Indicate whether the payment was successful "decision": "ACCEPT" if cls.PAYMENT_STATUS_RESPONSE == "success" else "REJECT", - # Reflect back whatever the client sent us, - # defaulting to `None` if a paramter wasn't received - "course_id": post_params.get('course_id'), - "orderAmount": post_params.get('amount'), - "ccAuthReply_amount": post_params.get('amount'), - "orderPage_transactionType": post_params.get('orderPage_transactionType'), - "orderPage_serialNumber": post_params.get('orderPage_serialNumber'), - "orderNumber": post_params.get('orderNumber'), - "orderCurrency": post_params.get('currency'), - "match": post_params.get('match'), - "merchantID": post_params.get('merchantID'), + # Reflect back parameters we were sent by the client + "req_amount": post_params.get('amount'), + "auth_amount": post_params.get('amount'), + "req_reference_number": post_params.get('reference_number'), + "req_transaction_uuid": post_params.get('transaction_uuid'), + "req_access_key": post_params.get('access_key'), + "req_transaction_type": post_params.get('transaction_type'), + "req_override_custom_receipt_page": post_params.get('override_custom_receipt_page'), + "req_payment_method": post_params.get('payment_method'), + "req_currency": post_params.get('currency'), + "req_locale": post_params.get('locale'), + "signed_date_time": post_params.get('signed_date_time'), - # Send fake user data - "billTo_firstName": "John", - "billTo_lastName": "Doe", - "billTo_street1": "123 Fake Street", - "billTo_state": "MA", - "billTo_city": "Boston", - "billTo_postalCode": "02134", - "billTo_country": "us", - - # Send fake data for other fields - "card_cardType": "001", - "card_accountNumber": "############1111", - "card_expirationMonth": "08", - "card_expirationYear": "2019", - "paymentOption": "card", - "orderPage_environment": "TEST", - "orderPage_requestToken": "unused", - "reconciliationID": "39093601YKVO1I5D", - "ccAuthReply_authorizationCode": "888888", - "ccAuthReply_avsCodeRaw": "I1", - "reasonCode": "100", - "requestID": "3777139938170178147615", - "ccAuthReply_reasonCode": "100", - "ccAuthReply_authorizedDateTime": "2013-08-28T181954Z", - "ccAuthReply_processorResponse": "100", - "ccAuthReply_avsCode": "X", - - # We don't use these signatures - "transactionSignature": "unused=", - "decision_publicSignature": "unused=", - "orderAmount_publicSignature": "unused=", - "orderNumber_publicSignature": "unused=", - "orderCurrency_publicSignature": "unused=", + # Fake data + "req_bill_to_address_city": "Boston", + "req_card_number": "xxxxxxxxxxxx1111", + "req_bill_to_address_state": "MA", + "req_bill_to_address_line1": "123 Fake Street", + "utf8": u"✓", + "reason_code": "100", + "req_card_expiry_date": "01-2018", + "req_bill_to_forename": "John", + "req_bill_to_surname": "Doe", + "auth_code": "888888", + "req_bill_to_address_postal_code": "02139", + "message": "Request was processed successfully.", + "auth_response": "100", + "auth_trans_ref_no": "84997128QYI23CJT", + "auth_time": "2014-08-18T110622Z", + "bill_trans_ref_no": "84997128QYI23CJT", + "auth_avs_code": "X", + "req_bill_to_email": "john@example.com", + "auth_avs_code_raw": "I1", + "req_profile_id": "0000001", + "req_card_type": "001", + "req_bill_to_address_country": "US", + "transaction_id": "4083599817820176195662", } # Indicate which fields we are including in the signature # Order is important signed_fields = [ - 'billTo_lastName', 'orderAmount', 'course_id', - 'billTo_street1', 'card_accountNumber', 'orderAmount_publicSignature', - 'orderPage_serialNumber', 'orderCurrency', 'reconciliationID', - 'decision', 'ccAuthReply_processorResponse', 'billTo_state', - 'billTo_firstName', 'card_expirationYear', 'billTo_city', - 'billTo_postalCode', 'orderPage_requestToken', 'ccAuthReply_amount', - 'orderCurrency_publicSignature', 'orderPage_transactionType', - 'ccAuthReply_authorizationCode', 'decision_publicSignature', - 'match', 'ccAuthReply_avsCodeRaw', 'paymentOption', - 'billTo_country', 'reasonCode', 'ccAuthReply_reasonCode', - 'orderPage_environment', 'card_expirationMonth', 'merchantID', - 'orderNumber_publicSignature', 'requestID', 'orderNumber', - 'ccAuthReply_authorizedDateTime', 'card_cardType', 'ccAuthReply_avsCode' + 'transaction_id', 'decision', 'req_access_key', 'req_profile_id', + 'req_transaction_uuid', 'req_transaction_type', 'req_reference_number', + 'req_amount', 'req_currency', 'req_locale', + 'req_payment_method', 'req_override_custom_receipt_page', + 'req_bill_to_forename', 'req_bill_to_surname', + 'req_bill_to_email', 'req_bill_to_address_line1', + 'req_bill_to_address_city', 'req_bill_to_address_state', + 'req_bill_to_address_country', 'req_bill_to_address_postal_code', + 'req_card_number', 'req_card_type', 'req_card_expiry_date', + 'message', 'reason_code', 'auth_avs_code', + 'auth_avs_code_raw', 'auth_response', 'auth_amount', + 'auth_code', 'auth_trans_ref_no', 'auth_time', + 'bill_trans_ref_no', 'signed_field_names', 'signed_date_time' ] # Add the list of signed fields - resp_params['signedFields'] = ",".join(signed_fields) - - # Calculate the fields signature - signed_fields_sig = processor_hash(resp_params['signedFields']) + resp_params['signed_field_names'] = ",".join(signed_fields) # Calculate the public signature hash_val = ",".join([ "{0}={1}".format(key, resp_params[key]) for key in signed_fields - ]) + ",signedFieldsPublicSignature={0}".format(signed_fields_sig) - - resp_params['signedDataPublicSignature'] = processor_hash(hash_val) + ]) + resp_params['signature'] = processor_hash(hash_val) return resp_params - def _payment_page_response(self, post_params, callback_url): + def _payment_page_response(self, post_params): """ Render the payment page to a response. This is an HTML form that triggers a POST request to `callback_url`. @@ -210,9 +194,10 @@ class PaymentFakeView(View): we either: 1) Use fake static data (e.g. always send user name "John Doe") - 2) Use the same info we received (e.g. send the same `course_id` and `amount`) + 2) Use the same info we received (e.g. send the same `amount`) 3) Dynamically calculate signatures using a shared secret """ + callback_url = post_params.get('override_custom_receipt_page', '/shoppingcart/postpay_callback/') # Build the context dict used to render the HTML form, # filling in values for the hidden input fields. diff --git a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py index d59767908f..17f5df9af5 100644 --- a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py +++ b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py @@ -3,8 +3,8 @@ Tests for the fake payment page used in acceptance tests. """ from django.test import TestCase -from shoppingcart.processors.CyberSource import sign, verify_signatures, \ - CCProcessorSignatureException +from shoppingcart.processors.CyberSource2 import sign, verify_signatures +from shoppingcart.processors.exceptions import CCProcessorSignatureException from shoppingcart.tests.payment_fake import PaymentFakeView from collections import OrderedDict @@ -16,16 +16,19 @@ class PaymentFakeViewTest(TestCase): """ CLIENT_POST_PARAMS = OrderedDict([ - ('match', 'on'), - ('course_id', 'edx/999/2013_Spring'), ('amount', '25.00'), ('currency', 'usd'), - ('orderPage_transactionType', 'sale'), + ('transaction_type', 'sale'), ('orderNumber', '33'), + ('access_key', '123456789'), ('merchantID', 'edx'), ('djch', '012345678912'), ('orderPage_version', 2), ('orderPage_serialNumber', '1234567890'), + ('profile_id', "00000001"), + ('reference_number', 10), + ('locale', 'en'), + ('signed_date_time', '2014-08-18T13:59:31Z'), ]) def setUp(self): @@ -58,7 +61,7 @@ class PaymentFakeViewTest(TestCase): post_params = sign(self.CLIENT_POST_PARAMS) # Tamper with the signature - post_params['orderPage_signaturePublic'] = "invalid" + post_params['signature'] = "invalid" # Simulate a POST request from the payment workflow # page to the fake payment page. diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index fd731ecfa9..e821c8eac3 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -72,21 +72,16 @@ def show_cart(request): total_cost = cart.total_cost cart_items = cart.orderitem_set.all() - # add the request protocol, domain, and port to the cart object so that any specific - # CC_PROCESSOR implementation can construct callback URLs, if necessary - cart.context = { - 'request_domain': '{0}://{1}'.format( - 'https' if request.is_secure() else 'http', - request.get_host() - ) + callback_url = request.build_absolute_uri( + reverse("shoppingcart.views.postpay_callback") + ) + form_html = render_purchase_form_html(cart, callback_url=callback_url) + context = { + 'shoppingcart_items': cart_items, + 'amount': total_cost, + 'form_html': form_html, } - - form_html = render_purchase_form_html(cart) - return render_to_response("shoppingcart/list.html", - {'shoppingcart_items': cart_items, - 'amount': total_cost, - 'form_html': form_html, - }) + return render_to_response("shoppingcart/list.html", context) @login_required diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 4ba9cf0898..4c02899c8c 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -24,17 +24,23 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config from xmodule.modulestore.tests.factories import CourseFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory from student.models import CourseEnrollment +from course_modes.tests.factories import CourseModeFactory from course_modes.models import CourseMode from verify_student.views import render_to_response from verify_student.models import SoftwareSecurePhotoVerification from reverification.tests.factories import MidcourseReverificationWindowFactory +# Since we don't need any XML course fixtures, use a modulestore configuration +# that disables the XML modulestore. +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + def mock_render_to_response(*args, **kwargs): return render_to_response(*args, **kwargs) @@ -58,8 +64,8 @@ class StartView(TestCase): self.assertHttpForbidden(self.client.get(self.start_url())) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestVerifyView(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestVerifyView(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create(username="rusty", password="test") self.client.login(username="rusty", password="test") @@ -93,8 +99,8 @@ class TestVerifyView(TestCase): self.assertIn("You are upgrading your registration for", response.content) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestVerifiedView(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestVerifiedView(ModuleStoreTestCase): """ Tests for VerifiedView. """ @@ -121,8 +127,8 @@ class TestVerifiedView(TestCase): self.assertIn('dashboard', response._headers.get('location')[1]) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestReverifyView(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestReverifyView(ModuleStoreTestCase): """ Tests for the reverification views @@ -167,8 +173,8 @@ class TestReverifyView(TestCase): self.assertTrue(context['error']) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestPhotoVerificationResultsCallback(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): """ Tests for the results_callback view. """ @@ -379,8 +385,8 @@ class TestPhotoVerificationResultsCallback(TestCase): self.assertIsNotNone(CourseEnrollment.objects.get(course_id=self.course_id)) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestMidCourseReverifyView(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestMidCourseReverifyView(ModuleStoreTestCase): """ Tests for the midcourse reverification views """ def setUp(self): self.user = UserFactory.create(username="rusty", password="test") @@ -490,8 +496,8 @@ class TestMidCourseReverifyView(TestCase): self.assertEquals(response.status_code, 200) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestReverificationBanner(TestCase): +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestReverificationBanner(ModuleStoreTestCase): """ Tests for the midcourse reverification failed toggle banner off """ @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @@ -511,3 +517,40 @@ class TestReverificationBanner(TestCase): self.client.post(reverse('verify_student_toggle_failed_banner_off')) photo_verification = SoftwareSecurePhotoVerification.objects.get(user=self.user, window=self.window) self.assertFalse(photo_verification.display) + + +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class TestCreateOrder(ModuleStoreTestCase): + """ Tests for the create order view. """ + + def setUp(self): + """ Create a user and course. """ + self.user = UserFactory.create(username="test", password="test") + self.course = CourseFactory.create() + for mode in ('audit', 'honor', 'verified'): + CourseModeFactory(mode_slug=mode, course_id=self.course.id) + self.client.login(username="test", password="test") + + def test_create_order_already_verified(self): + # Verify the student so we don't need to submit photos + self._verify_student() + + # Create an order + url = reverse('verify_student_create_order') + params = { + 'course_id': unicode(self.course.id), + } + response = self.client.post(url, params) + self.assertEqual(response.status_code, 200) + + # Verify that the information will be sent to the correct callback URL + # (configured by test settings) + data = json.loads(response.content) + self.assertEqual(data['override_custom_receipt_page'], "http://testserver/shoppingcart/postpay_callback/") + + def _verify_student(self): + """ Simulate that the student's identity has already been verified. """ + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 1d28c2665c..0ed9566ee9 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -25,7 +25,7 @@ from course_modes.models import CourseMode from student.models import CourseEnrollment from student.views import reverification_info from shoppingcart.models import Order, CertificateItem -from shoppingcart.processors.CyberSource import ( +from shoppingcart.processors import ( get_signed_purchase_params, get_purchase_endpoint ) from verify_student.models import ( @@ -219,7 +219,12 @@ def create_order(request): enrollment_mode = current_mode.slug CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode) - params = get_signed_purchase_params(cart) + callback_url = request.build_absolute_uri( + reverse("shoppingcart.views.postpay_callback") + ) + params = get_signed_purchase_params( + cart, callback_url=callback_url + ) return HttpResponse(json.dumps(params), content_type="text/json") diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index c5d28db898..fa0d1c2752 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -117,20 +117,6 @@ FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # verification. FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True -# Configure the payment processor to use the fake processing page -# Since both the fake payment page and the shoppingcart app are using -# the same settings, we can generate this randomly and guarantee -# that they are using the same secret. -RANDOM_SHARED_SECRET = ''.join( - choice(string.letters + string.digits + string.punctuation) - for x in range(250) -) - -CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET -CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" -CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" -CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" - # HACK # Setting this flag to false causes imports to not load correctly in the lettuce python files # We do not yet understand why this occurs. Setting this to true is a stopgap measure diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 900aa633a7..f06548fc23 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -301,6 +301,7 @@ SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) +CC_PROCESSOR_NAME = AUTH_TOKENS.get('CC_PROCESSOR_NAME', CC_PROCESSOR_NAME) CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index 7d3611e665..68fb82fded 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -757,7 +757,10 @@ EMBARGO_SITE_REDIRECT_URL = None ##### shoppingcart Payment ##### PAYMENT_SUPPORT_EMAIL = 'payment@example.com' + ##### Using cybersource by default ##### + +CC_PROCESSOR_NAME = 'CyberSource' CC_PROCESSOR = { 'CyberSource': { 'SHARED_SECRET': '', @@ -765,8 +768,15 @@ CC_PROCESSOR = { 'SERIAL_NUMBER': '', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', + }, + 'CyberSource2': { + "PURCHASE_ENDPOINT": '', + "SECRET_KEY": '', + "ACCESS_KEY": '', + "PROFILE_ID": '', } } + # Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$'] diff --git a/lms/envs/dev.py b/lms/envs/dev.py index c6aae3dc78..1193c84e34 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -281,9 +281,13 @@ if SEGMENT_IO_LMS_KEY: CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '') CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') -#CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' +CC_PROCESSOR['CyberSource2']['SECRET_KEY'] = os.environ.get('CYBERSOURCE_SECRET_KEY', '') +CC_PROCESSOR['CyberSource2']['ACCESS_KEY'] = os.environ.get('CYBERSOURCE_ACCESS_KEY', '') +CC_PROCESSOR['CyberSource2']['PROFILE_ID'] = os.environ.get('CYBERSOURCE_PROFILE_ID', '') +CC_PROCESSOR['CyberSource2']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' + ########################## USER API ########################## EDX_API_KEY = None diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 79dc492570..dd70d63c67 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -83,7 +83,9 @@ PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True FEATURES['ENABLE_PAYMENT_FAKE'] = True -CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' + +for processor in CC_PROCESSOR.values(): + processor['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index 2a8231dfc9..f5c8f06668 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -220,6 +220,7 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] ###################### Payment ##############################3 # Enable fake payment processing page FEATURES['ENABLE_PAYMENT_FAKE'] = True + # Configure the payment processor to use the fake processing page # Since both the fake payment page and the shoppingcart app are using # the same settings, we can generate this randomly and guarantee @@ -231,10 +232,13 @@ RANDOM_SHARED_SECRET = ''.join( for x in range(250) ) -CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET -CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" -CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" -CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" +CC_PROCESSOR_NAME = 'CyberSource2' +CC_PROCESSOR['CyberSource2']['SECRET_KEY'] = RANDOM_SHARED_SECRET +CC_PROCESSOR['CyberSource2']['ACCESS_KEY'] = "0123456789012345678901" +CC_PROCESSOR['CyberSource2']['PROFILE_ID'] = "edx" +CC_PROCESSOR['CyberSource2']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + +FEATURES['STORE_BILLING_INFO'] = True ########################### SYSADMIN DASHBOARD ################################ FEATURES['ENABLE_SYSADMIN_DASHBOARD'] = True