Commit be1523f2 by Will Daly

Merge pull request #4880 from edx/will/use-new-cybersource-api

CyberSource API Update
parents c2e804ec 1ad8d4f2
### 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": "<shared secret>",
"MERCHANT_ID": "<merchant ID>",
"SERIAL_NUMBER": "<serial number>",
"PURCHASE_ENDPOINT": "<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):
"""
......
### 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.
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": "<secret key>",
"ACCESS_KEY": "<access key>",
"PROFILE_ID": "<profile ID>",
"PURCHASE_ENDPOINT": "<purchase endpoint>"
}
}
### 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
"""
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
def process_postpay_callback(params):
"""
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']
Handle a response from the payment processor.
return config
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
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.
"""
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.
Returns:
unicode
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
return get_processor_config().get('PURCHASE_ENDPOINT', '')
raises: CCProcessorDataException if the returned message did not provide required parameters
CCProcessorWrongAmountException if the amount charged is different than the order amount
def _payment_accepted(order_id, auth_amount, currency, decision):
"""
#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))
)
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"""
payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
def _get_processor_decline_html(params):
"""
Return HTML indicating that the user's payment was declined.
msg = dedent(_(
"""
<p class="error_msg">
Sorry! Our payment processor did not accept your payment.
The decision they returned was <span class="decision">{decision}</span>,
and the reason was <span class="reason">{reason_code}:{reason_msg}</span>.
You were not charged. Please try a different form of payment.
Contact us with payment-related questions at {email}.
</p>
"""))
return msg.format(
decision=params['decision'],
reason_code=params['reason_code'],
reason_msg=REASONCODE_MAP[params['reason_code']],
email=payment_support_email
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)
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='<span class="decision">{decision}</span>'.format(decision=params['decision']),
reason='<span class="reason">{reason_code}:{reason_msg}</span>'.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(_(
"""
<p class="error_msg">
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: <span class="exception_msg">{msg}</span>.
Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.
</p>
""".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'<span class="exception_msg">{msg}</span>'.format(msg=exception.message),
email=payment_support_email
)
)
elif isinstance(exception, CCProcessorWrongAmountException):
msg = dedent(_(
"""
<p class="error_msg">
Sorry! Due to an error your purchase was charged for a different amount than the order total!
The specific error message is: <span class="exception_msg">{msg}</span>.
Your credit card has probably been charged. Contact us with payment-specific questions at {email}.
</p>
""".format(msg=exception.message, email=payment_support_email)))
return msg
# fallthrough case, which basically never happens
return '<p class="error_msg">EXCEPTION!</p>'
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'<span class="exception_msg">{msg}</span>'.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'<span class="exception_msg">{msg}</span>'.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)
)
def _format_error_html(msg):
""" Format an HTML error message """
return '<p class="error_msg">{msg}</p>'.format(msg=msg)
CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN")
......
"""
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(cart, **kwargs):
"""
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
def render_purchase_form_html(*args, **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
return PROCESSOR_MODULE.render_purchase_form_html(cart, **kwargs)
def process_postpay_callback(params, **kwargs):
"""
return module.render_purchase_form_html(*args, **kwargs)
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
def process_postpay_callback(*args, **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.
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 module.process_postpay_callback(*args, **kwargs)
return PROCESSOR_MODULE.get_signed_purchase_params(cart, **kwargs)
"""
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
......@@ -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')
......
# -*- 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('<form action="/shoppingcart/payment_fake" method="post">', 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(",")
])
)
# -*- 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'),
# 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=",
# 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'),
# 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.
......
......@@ -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.
......
......@@ -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
......
......@@ -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()
......@@ -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")
......
......@@ -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
......
......@@ -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']
......
......@@ -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', '$']
......
......@@ -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
......
......@@ -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.
......
......@@ -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
......
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