Merge pull request #4880 from edx/will/use-new-cybersource-api
CyberSource API Update
This commit is contained in:
@@ -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": "<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):
|
||||
"""
|
||||
|
||||
@@ -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": "<secret key>",
|
||||
"ACCESS_KEY": "<access key>",
|
||||
"PROFILE_ID": "<profile ID>",
|
||||
"PURCHASE_ENDPOINT": "<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(_(
|
||||
"""
|
||||
<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
|
||||
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
|
||||
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)
|
||||
)
|
||||
|
||||
# fallthrough case, which basically never happens
|
||||
return '<p class="error_msg">EXCEPTION!</p>'
|
||||
|
||||
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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
lms/djangoapps/shoppingcart/processors/helpers.py
Normal file
31
lms/djangoapps/shoppingcart/processors/helpers.py
Normal file
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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('<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(",")
|
||||
])
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user