From ca3651fa355d6ee4b0556e0453da620e9aa0bc77 Mon Sep 17 00:00:00 2001
From: Jason Bau
Date: Tue, 20 Aug 2013 18:17:06 -0700
Subject: [PATCH] add handling of CyberSource non-ACCEPT decisions
---
.../shoppingcart/processors/CyberSource.py | 242 +++++++++++++++---
.../shoppingcart/processors/exceptions.py | 3 +
lms/djangoapps/shoppingcart/views.py | 4 +-
lms/templates/shoppingcart/error.html | 14 +
4 files changed, 226 insertions(+), 37 deletions(-)
create mode 100644 lms/templates/shoppingcart/error.html
diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py
index 75ad754237..d8e53843cc 100644
--- a/lms/djangoapps/shoppingcart/processors/CyberSource.py
+++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py
@@ -8,18 +8,21 @@ import binascii
import re
import json
from collections import OrderedDict, defaultdict
+from decimal import Decimal, InvalidOperation
from hashlib import sha1
+from textwrap import dedent
from django.conf import settings
from django.utils.translation import ugettext as _
from mitxmako.shortcuts import render_to_string
from shoppingcart.models import Order
-from .exceptions import CCProcessorException, CCProcessorDataException, CCProcessorWrongAmountException
+from .exceptions import *
shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','')
merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','')
serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','')
orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7')
purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','')
+payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
def process_postpay_callback(request):
"""
@@ -34,27 +37,23 @@ def process_postpay_callback(request):
return a helpful-enough error message in error_html.
"""
params = request.POST.dict()
- if verify_signatures(params):
- try:
- result = payment_accepted(params)
- 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': ''}
- else:
- return {'success': False,
- 'order': result['order'],
- 'error_html': get_processor_error_html(params)}
- except CCProcessorException as e:
+ try:
+ verify_signatures(params)
+ result = payment_accepted(params)
+ 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': ''}
+ else:
return {'success': False,
- 'order': None, #due to exception we may not have the order
- 'error_html': get_exception_html(params, e)}
- else:
+ 'order': result['order'],
+ 'error_html': get_processor_decline_html(params)}
+ except CCProcessorException as e:
return {'success': False,
- 'order': None,
- 'error_html': get_signature_error_html(params)}
+ 'order': None, #due to exception we may not have the order
+ 'error_html': get_processor_exception_html(params, e)}
def hash(value):
@@ -87,15 +86,18 @@ def sign(params):
def verify_signatures(params):
"""
Verify the signatures accompanying the POST back from Cybersource Hosted Order Page
+
+ returns silently if verified
+
+ raises CCProcessorSignatureException if not verified
"""
signed_fields = params.get('signedFields', '').split(',')
data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields])
signed_fields_sig = hash(params.get('signedFields', ''))
data += ",signedFieldsPublicSignature=" + signed_fields_sig
returned_sig = params.get('signedDataPublicSignature','')
- if not returned_sig:
- return False
- return hash(data) == returned_sig
+ if hash(data) != returned_sig:
+ raise CCProcessorSignatureException()
def render_purchase_form_html(cart, user):
@@ -130,11 +132,18 @@ def render_purchase_form_html(cart, user):
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
+
"""
#make sure required keys are present and convert their values to the right type
valid_params = {}
for key, type in [('orderNumber', int),
- ('ccAuthReply_amount', float),
('orderCurrency', str),
('decision', str)]:
if key not in params:
@@ -154,7 +163,16 @@ def payment_accepted(params):
raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system."))
if valid_params['decision'] == 'ACCEPT':
- if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency:
+ try:
+ # Moved reading of charged_amount from the valid_params loop above because
+ # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter
+ charged_amt = Decimal(params['ccAuthReply_amount'])
+ except InvalidOperation:
+ raise CCProcessorDataException(
+ _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key))
+ )
+
+ if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency:
return {'accepted': True,
'amt_charged': valid_params['ccAuthReply_amount'],
'currency': valid_params['orderCurrency'],
@@ -197,21 +215,67 @@ def record_purchase(params, order):
processor_reply_dump=json.dumps(params)
)
-def get_processor_error_html(params):
- """Have to parse through the error codes for all the other cases"""
- return "ERROR!
"
+def get_processor_decline_html(params):
+ """Have to parse through the error codes to return a helpful message"""
+ msg = _(dedent(
+ """
+
+ Sorry! Our payment processor did not accept your payment.
+ The decision in they returned was {decision},
+ and the reason was {reason_code}:{reason_msg}.
+ You were not charged. Please try a different form of payment.
+ Contact us with payment-specific questions at {email}.
+
+ """))
-def get_exception_html(params, exp):
+ return msg.format(
+ decision=params['decision'],
+ reason_code=params['reasonCode'],
+ reason_msg=REASONCODE_MAP[params['reasonCode']],
+ email=payment_support_email)
+
+
+def get_processor_exception_html(params, exception):
"""Return error HTML associated with exception"""
- return "EXCEPTION!
"
-def get_signature_error_html(params):
- """Return error HTML associated with signature failure"""
- return "EXCEPTION!
"
+ if isinstance(exception, CCProcessorDataException):
+ msg = _(dedent(
+ """
+
+ Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data!
+ We apologize that we cannot verify whether the charge went through and take further action on your order.
+ The specific error message is: {msg}.
+ Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.
+
+ """.format(msg=exception.message, email=payment_support_email)))
+ return msg
+ elif isinstance(exception, CCProcessorWrongAmountException):
+ msg = _(dedent(
+ """
+
+ Sorry! Due to an error your purchase was charged for a different amount than the order total!
+ The specific error message is: {msg}.
+ Your credit card has probably been charged. Contact us with payment-specific questions at {email}.
+
+ """.format(msg=exception.message, email=payment_support_email)))
+ return msg
+ elif isinstance(exception, CCProcessorSignatureException):
+ msg = _(dedent(
+ """
+
+ Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are
+ unable to validate that the message actually came from the payment processor.
+ We apologize that we cannot verify whether the charge went through and take further action on your order.
+ Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.
+
+ """.format(email=payment_support_email)))
+ return msg
+
+ # fallthrough case, which basically never happens
+ return 'EXCEPTION!
'
-CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN")
-CARDTYPE_MAP.update(
+CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN").update(
{
'001': 'Visa',
'002': 'MasterCard',
@@ -233,3 +297,111 @@ CARDTYPE_MAP.update(
'043': 'GE Money UK card'
}
)
+
+REASONCODE_MAP = defaultdict(lambda:"UNKNOWN REASON")
+REASONCODE_MAP.update(
+ {
+ '100' : _('Successful transaction.'),
+ '101' : _('The request is missing one or more required fields.'),
+ '102' : _('One or more fields in the request contains invalid data.'),
+ '104' : _(dedent(
+ """
+ The merchantReferenceCode sent with this authorization request matches the
+ merchantReferenceCode of another authorization request that you sent in the last 15 minutes.
+ Possible fix: retry the payment after 15 minutes.
+ """)),
+ '150' : _('Error: General system failure. Possible fix: retry the payment after a few minutes.'),
+ '151' : _(dedent(
+ """
+ Error: The request was received but there was a server timeout.
+ This error does not include timeouts between the client and the server.
+ Possible fix: retry the payment after some time.
+ """)),
+ '152' : _(dedent(
+ """
+ Error: The request was received, but a service did not finish running in time
+ Possible fix: retry the payment after some time.
+ """)),
+ '201' : _('The issuing bank has questions about the request. 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' : _('Unknown reason'),
+ '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. 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' : _('Unknown reason'),
+ '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 our CyberSource merchant configuration. Please let us know at {0}
+ """.format(payment_support_email))),
+ # reason code 235 only applies if we are processing a capture through the API. so we should never see it
+ '235' : _('The requested amount exceeds the originally authorized amount.'),
+ '236' : _('Processor Failure. Possible fix: retry the payment'),
+ # reason code 238 only applies if we are processing a capture through the API. so we should never see it
+ '238' : _('The authorization has already been captured'),
+ # reason code 239 only applies if we are processing a capture or credit through the API,
+ # so we should never see it
+ '239' : _('The requested transaction amount must match the previous transaction amount.'),
+ '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
+ """)),
+ # reason code 241 only applies when we are processing a capture or credit through the API,
+ # so we should never see it
+ '241' : _('The request ID is invalid.'),
+ # reason code 242 occurs if there was not a previously successful authorization request or
+ # if the previously successful authorization has already been used by another capture request.
+ # This reason code only applies when we are processing a capture through the API
+ # so we should never see it
+ '242' : _(dedent(
+ """
+ You requested a capture through the API, but there is no corresponding, unused authorization record.
+ """)),
+ # we should never see 243
+ '243' : _('The transaction has already been settled or reversed.'),
+ # reason code 246 applies only if we are processing a void through the API. so we should never see it
+ '246' : _(dedent(
+ """
+ The capture or credit is not voidable because the capture or credit information has already been
+ submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided.
+ """)),
+ # reason code 247 applies only if we are processing a void through the API. so we should never see it
+ '247' : _('You requested a credit for a capture that was previously voided'),
+ '250' : _(dedent(
+ """
+ Error: The request was received, but there was a timeout at the payment processor.
+ Possible fix: retry the payment.
+ """)),
+ '520' : _(dedent(
+ """
+ The authorization request was approved by the issuing bank but declined by CyberSource.'
+ Possible fix: retry with a different form of payment.
+ """)),
+ }
+)
\ No newline at end of file
diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py
index 098ed0f1af..6779ac11a6 100644
--- a/lms/djangoapps/shoppingcart/processors/exceptions.py
+++ b/lms/djangoapps/shoppingcart/processors/exceptions.py
@@ -3,6 +3,9 @@ from shoppingcart.exceptions import PaymentException
class CCProcessorException(PaymentException):
pass
+class CCProcessorSignatureException(CCProcessorException):
+ pass
+
class CCProcessorDataException(CCProcessorException):
pass
diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py
index 52837228b9..85334df6a6 100644
--- a/lms/djangoapps/shoppingcart/views.py
+++ b/lms/djangoapps/shoppingcart/views.py
@@ -86,8 +86,8 @@ def postpay_callback(request):
if result['success']:
return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id]))
else:
- return render_to_response('shoppingcart.processor_error.html', {'order':result['order'],
- 'error_html': result['error_html']})
+ return render_to_response('shoppingcart/error.html', {'order':result['order'],
+ 'error_html': result['error_html']})
@login_required
def show_receipt(request, ordernum):
diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html
new file mode 100644
index 0000000000..da88dc1a78
--- /dev/null
+++ b/lms/templates/shoppingcart/error.html
@@ -0,0 +1,14 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%! from django.core.urlresolvers import reverse %>
+
+<%inherit file="../main.html" />
+
+<%block name="title">${_("Payment Error")}%block>
+
+
+
+ ${_("There was an error processing your order!")}
+ ${error_html}
+
+ ${_("Return to cart to retry payment")}
+