Files
edx-platform/lms/djangoapps/shoppingcart/processors/CyberSource2.py
Will Daly f8365a2d3b Add donation end-point
Make donations configurable

Added donation button to dashboard

Generalize merchant defined data for payment processor
2014-10-07 14:22:55 -04:00

615 lines
23 KiB
Python

"""
Implementation of the CyberSource credit card processor using the newer "Secure Acceptance API".
The previous Hosted Order Page API is being deprecated as of 9/14.
For now, we're keeping the older implementation in the code-base so we can
quickly roll-back by updating the configuration. Eventually, we should replace
the original implementation with this version.
To enable this implementation, add the following Django settings:
CC_PROCESSOR_NAME = "CyberSource2"
CC_PROCESSOR = {
"CyberSource2": {
"SECRET_KEY": "<secret key>",
"ACCESS_KEY": "<access key>",
"PROFILE_ID": "<profile ID>",
"PURCHASE_ENDPOINT": "<purchase endpoint>"
}
}
"""
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 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
def process_postpay_callback(params):
"""
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:
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']:
_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)
}
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)
}
def processor_hash(value):
"""
Calculate the base64-encoded, SHA-256 hash used by CyberSource.
Args:
value (string): The value to encode.
Returns:
string
"""
secret_key = get_processor_config().get('SECRET_KEY', '')
hash_obj = hmac.new(secret_key.encode('utf-8'), value.encode('utf-8'), sha256)
return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want
def verify_signatures(params):
"""
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)
"""
# First see if the user cancelled the transaction
# if so, then not all parameters will be passed back so we can't yet verify signatures
if params.get('decision') == u'CANCEL':
raise CCProcessorUserCancelled()
# 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_field_names'] = fields
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['signature'] = processor_hash(values)
params['signed_field_names'] = fields
return params
def render_purchase_form_html(cart, callback_url=None, extra_data=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).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
unicode: The rendered HTML form.
"""
return render_to_string('shoppingcart/cybersource_form.html', {
'action': get_purchase_endpoint(),
'params': get_signed_purchase_params(
cart, callback_url=callback_url, extra_data=extra_data
),
})
def get_signed_purchase_params(cart, callback_url=None, extra_data=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).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
dict
"""
return sign(get_purchase_params(cart, callback_url=callback_url, extra_data=extra_data))
def get_purchase_params(cart, callback_url=None, extra_data=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).
extra_data (list): Additional data to include as merchant-defined data fields.
Returns:
dict
"""
total_cost = cart.total_cost
amount = "{0:0.2f}".format(total_cost)
params = OrderedDict()
params['amount'] = amount
params['currency'] = cart.currency
params['orderNumber'] = "OrderId: {0:d}".format(cart.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'
params['locale'] = 'en'
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().hex
params['payment_method'] = 'card'
if callback_url is not None:
params['override_custom_receipt_page'] = callback_url
params['override_custom_cancel_page'] = callback_url
if extra_data is not None:
# CyberSource allows us to send additional data in "merchant defined data" fields
for num, item in enumerate(extra_data, start=1):
key = u"merchant_defined_data{num}".format(num=num)
params[key] = item
return params
def get_purchase_endpoint():
"""
Return the URL of the payment end-point for CyberSource.
Returns:
unicode
"""
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=order_id)
except Order.DoesNotExist:
raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system."))
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(
_(
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
}
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:
ccnum = ccnum_str[mm.start():]
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', ''),
street1=params.get('req_bill_to_address_line1', ''),
street2=params.get('req_bill_to_address_line2', ''),
city=params.get('req_bill_to_address_city', ''),
state=params.get('req_bill_to_address_state', ''),
country=params.get('req_bill_to_address_country', ''),
postalcode=params.get('req_bill_to_address_postal_code', ''),
ccnum=ccnum,
cardtype=CARDTYPE_MAP[params.get('req_card_type', '')],
processor_reply_dump=json.dumps(params)
)
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)
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 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):
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):
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
)
)
elif isinstance(exception, CCProcessorUserCancelled):
return _format_error_html(
_(
u"Sorry! Our payment processor sent us back a message saying that you have cancelled this transaction. "
u"The items in your shopping cart will exist for future purchase. "
u"If you feel that this is in error, please contact us with payment-specific questions at {email}."
).format(
email=payment_support_email
)
)
else:
return _format_error_html(
_(
u"Sorry! Your payment could not be processed because an unexpected exception occurred. "
u"Please contact us at {email} for assistance."
).format(email=payment_support_email)
)
def _format_error_html(msg):
""" Format an HTML error message """
return '<p class="error_msg">{msg}</p>'.format(msg=msg)
CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN")
CARDTYPE_MAP.update(
{
'001': 'Visa',
'002': 'MasterCard',
'003': 'American Express',
'004': 'Discover',
'005': 'Diners Club',
'006': 'Carte Blanche',
'007': 'JCB',
'014': 'EnRoute',
'021': 'JAL',
'024': 'Maestro',
'031': 'Delta',
'033': 'Visa Electron',
'034': 'Dankort',
'035': 'Laser',
'036': 'Carte Bleue',
'037': 'Carta Si',
'042': 'Maestro Int.',
'043': 'GE Money UK card'
}
)
REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON")
REASONCODE_MAP.update(
{
'100': _('Successful transaction.'),
'102': _('One or more fields in the request contains invalid data.'),
'104': dedent(_(
"""
The access_key and transaction_uuid fields for this authorization request matches the access_key and
transaction_uuid of another authorization request that you sent in the last 15 minutes.
Possible fix: retry the payment after 15 minutes.
""")),
'110': _('Only a partial amount was approved.'),
'200': dedent(_(
"""
The authorization request was approved by the issuing bank but declined by CyberSource
because it did not pass the Address Verification System (AVS).
""")),
'201': dedent(_(
"""
The issuing bank has questions about the request. You do not receive an
authorization code programmatically, but you might receive one verbally by calling the processor.
Possible fix: retry with another form of payment
""")),
'202': dedent(_(
"""
Expired card. You might also receive this if the expiration date you
provided does not match the date the issuing bank has on file.
Possible fix: retry with another form of payment
""")),
'203': dedent(_(
"""
General decline of the card. No other information provided by the issuing bank.
Possible fix: retry with another form of payment
""")),
'204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'),
# 205 was Stolen or lost card. Might as well not show this message to the person using such a card.
'205': _('Stolen or lost card'),
'207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'),
'208': dedent(_(
"""
Inactive card or card not authorized for card-not-present transactions.
Possible fix: retry with another form of payment
""")),
'210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'),
'211': _('Invalid card verification number (CVN). Possible fix: retry with another form of payment'),
# 221 was The customer matched an entry on the processor's negative file.
# Might as well not show this message to the person using such a card.
'221': _('The customer matched an entry on the processors negative file.'),
'222': _('Account frozen. Possible fix: retry with another form of payment'),
'230': dedent(_(
"""
The authorization request was approved by the issuing bank but declined by
CyberSource because it did not pass the CVN check.
Possible fix: retry with another form of payment
""")),
'231': _('Invalid account number. Possible fix: retry with another form of payment'),
'232': dedent(_(
"""
The card type is not accepted by the payment processor.
Possible fix: retry with another form of payment
""")),
'233': _('General decline by the processor. Possible fix: retry with another form of payment'),
'234': dedent(_(
"""
There is a problem with the information in your CyberSource account. Please let us know at {0}
""".format(settings.PAYMENT_SUPPORT_EMAIL))),
'236': _('Processor Failure. Possible fix: retry the payment'),
'240': dedent(_(
"""
The card type sent is invalid or does not correlate with the credit card number.
Possible fix: retry with the same card or another form of payment
""")),
'475': _('The cardholder is enrolled for payer authentication'),
'476': _('Payer authentication could not be authenticated'),
'520': dedent(_(
"""
The authorization request was approved by the issuing bank but declined by CyberSource based
on your legacy Smart Authorization settings.
Possible fix: retry with a different form of payment.
""")),
}
)