Add verification for CyberSource2 implementation.
Clean up shopping cart processor API. Fix UUID JSON serialization bug in CyberSource2 implementation. Update test suite to use new CyberSource2 implementation. Fix i18n messages in CyberSource2 Enable CyberSource2 implementation by default.
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