CyberSource.py 19.4 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
"""
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>"
        }
    }

"""
21 22 23
import time
import hmac
import binascii
24 25 26
import re
import json
from collections import OrderedDict, defaultdict
27
from decimal import Decimal, InvalidOperation
28
from hashlib import sha1
29
from textwrap import dedent
30
from django.conf import settings
31
from django.utils.translation import ugettext as _
David Baumgold committed
32
from edxmako.shortcuts import render_to_string
33
from shoppingcart.models import Order
Diana Huang committed
34
from shoppingcart.processors.exceptions import *
35
from shoppingcart.processors.helpers import get_processor_config
36
from microsite_configuration import microsite
Diana Huang committed
37

38

39
def process_postpay_callback(params, **kwargs):
40 41 42 43 44 45 46 47 48 49 50
    """
    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.
    """
51 52 53 54 55 56 57 58 59 60
    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:
61
            return {'success': False,
62 63
                    'order': result['order'],
                    'error_html': get_processor_decline_html(params)}
Diana Huang committed
64
    except CCProcessorException as error:
65
        return {'success': False,
Diana Huang committed
66 67
                'order': None,  # due to exception we may not have the order
                'error_html': get_processor_exception_html(error)}
68 69


Diana Huang committed
70
def processor_hash(value):
71 72 73
    """
    Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page
    """
74
    shared_secret = get_processor_config().get('SHARED_SECRET', '')
75
    hash_obj = hmac.new(shared_secret.encode('utf-8'), value.encode('utf-8'), sha1)
Diana Huang committed
76
    return binascii.b2a_base64(hash_obj.digest())[:-1]  # last character is a '\n', which we don't want
77 78


79
def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'):
80 81 82 83
    """
    params needs to be an ordered dict, b/c cybersource documentation states that order is important.
    Reverse engineered from PHP version provided by cybersource
    """
84 85 86
    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', '')
87

88
    params['merchantID'] = merchant_id
Diana Huang committed
89 90
    params['orderPage_timestamp'] = int(time.time() * 1000)
    params['orderPage_version'] = order_page_version
91
    params['orderPage_serialNumber'] = serial_number
92 93
    fields = u",".join(params.keys())
    values = u",".join([u"{0}={1}".format(i, params[i]) for i in params.keys()])
Diana Huang committed
94
    fields_sig = processor_hash(fields)
95
    values += u",signedFieldsPublicSignature=" + fields_sig
Diana Huang committed
96
    params[full_sig_key] = processor_hash(values)
97
    params[signed_fields_key] = fields
98 99 100

    return params

101

102
def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='signedDataPublicSignature'):
103 104
    """
    Verify the signatures accompanying the POST back from Cybersource Hosted Order Page
105 106 107 108

    returns silently if verified

    raises CCProcessorSignatureException if not verified
109
    """
110
    signed_fields = params.get(signed_fields_key, '').split(',')
111
    data = u",".join([u"{0}={1}".format(k, params.get(k, '')) for k in signed_fields])
Diana Huang committed
112
    signed_fields_sig = processor_hash(params.get(signed_fields_key, ''))
113
    data += u",signedFieldsPublicSignature=" + signed_fields_sig
114
    returned_sig = params.get(full_sig_key, '')
Diana Huang committed
115
    if processor_hash(data) != returned_sig:
116
        raise CCProcessorSignatureException()
117

118

119
def render_purchase_form_html(cart, **kwargs):
120 121 122
    """
    Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource
    """
123
    return render_to_string('shoppingcart/cybersource_form.html', {
124
        'action': get_purchase_endpoint(),
125
        'params': get_signed_purchase_params(cart),
126 127
    })

128 129

def get_signed_purchase_params(cart, **kwargs):
130 131
    return sign(get_purchase_params(cart))

132

133
def get_purchase_params(cart):
134 135 136 137 138 139 140 141 142
    total_cost = cart.total_cost
    amount = "{0:0.2f}".format(total_cost)
    cart_items = cart.orderitem_set.all()
    params = OrderedDict()
    params['amount'] = amount
    params['currency'] = cart.currency
    params['orderPage_transactionType'] = 'sale'
    params['orderNumber'] = "{0:d}".format(cart.id)

143
    return params
144

145

146
def get_purchase_endpoint():
147 148
    return get_processor_config().get('PURCHASE_ENDPOINT', '')

149

150 151 152
def payment_accepted(params):
    """
    Check that cybersource has accepted the payment
153 154 155 156 157 158 159 160
    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

161 162 163
    """
    #make sure required keys are present and convert their values to the right type
    valid_params = {}
Diana Huang committed
164 165 166
    for key, key_type in [('orderNumber', int),
                          ('orderCurrency', str),
                          ('decision', str)]:
167 168
        if key not in params:
            raise CCProcessorDataException(
169
                _("The payment processor did not return a required parameter: {0}").format(key)
170 171
            )
        try:
Diana Huang committed
172
            valid_params[key] = key_type(params[key])
173 174
        except ValueError:
            raise CCProcessorDataException(
175
                _("The payment processor returned a badly-typed value {0} for param {1}.").format(params[key], key)
176 177 178 179 180 181 182 183
            )

    try:
        order = Order.objects.get(id=valid_params['orderNumber'])
    except Order.DoesNotExist:
        raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system."))

    if valid_params['decision'] == 'ACCEPT':
184
        try:
185
            # Moved reading of charged_amount here from the valid_params loop above because
186 187 188 189
            # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter
            charged_amt = Decimal(params['ccAuthReply_amount'])
        except InvalidOperation:
            raise CCProcessorDataException(
190 191 192
                _("The payment processor returned a badly-typed value {0} for param {1}.").format(
                    params['ccAuthReply_amount'], 'ccAuthReply_amount'
                )
193 194 195
            )

        if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency:
196
            return {'accepted': True,
197
                    'amt_charged': charged_amt,
198 199 200 201
                    'currency': valid_params['orderCurrency'],
                    'order': order}
        else:
            raise CCProcessorWrongAmountException(
202 203 204 205 206 207 208
                _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}.")
                .format(
                    charged_amt,
                    valid_params['orderCurrency'],
                    order.total_cost,
                    order.currency
                )
209 210 211 212 213
            )
    else:
        return {'accepted': False,
                'amt_charged': 0,
                'currency': 'usd',
214
                'order': order}
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235


def record_purchase(params, order):
    """
    Record the purchase and run purchased_callbacks
    """
    ccnum_str = params.get('card_accountNumber', '')
    m = re.search("\d", ccnum_str)
    if m:
        ccnum = ccnum_str[m.start():]
    else:
        ccnum = "####"

    order.purchase(
        first=params.get('billTo_firstName', ''),
        last=params.get('billTo_lastName', ''),
        street1=params.get('billTo_street1', ''),
        street2=params.get('billTo_street2', ''),
        city=params.get('billTo_city', ''),
        state=params.get('billTo_state', ''),
        country=params.get('billTo_country', ''),
Diana Huang committed
236
        postalcode=params.get('billTo_postalCode', ''),
237 238 239 240 241
        ccnum=ccnum,
        cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')],
        processor_reply_dump=json.dumps(params)
    )

Diana Huang committed
242

243 244
def get_processor_decline_html(params):
    """Have to parse through the error codes to return a helpful message"""
245 246 247

    # see if we have an override in the microsites
    payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
248

249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
    msg = _(
        "Sorry! Our payment processor did not accept your payment. "
        "The decision they returned was {decision_text}, "
        "and the reason was {reason_text}. "
        "You were not charged. "
        "Please try a different form of payment. "
        "Contact us with payment-related questions at {email}."
    )
    formatted = msg.format(
        decision_text='<span class="decision">{}</span>'.format(params['decision']),
        reason_text='<span class="reason">{code}:{msg}</span>'.format(
            code=params['reasonCode'], msg=REASONCODE_MAP[params['reasonCode']],
        ),
        email=payment_support_email,
    )
    return '<p class="error_msg">{}</p>'.format(formatted)
265

266

267
def get_processor_exception_html(exception):
268 269
    """Return error HTML associated with exception"""

270 271
    # see if we have an override in the microsites
    payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL)
272
    if isinstance(exception, CCProcessorDataException):
273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
        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: {error_message}. "
            "Your credit card may possibly have been charged. "
            "Contact us with payment-specific questions at {email}."
        )
        formatted = msg.format(
            error_message='<span class="exception_msg">{msg}</span>'.format(
                msg=exception.message,
            ),
            email=payment_support_email,
        )
        return '<p class="error_msg">{}</p>'.format(formatted)
289
    elif isinstance(exception, CCProcessorWrongAmountException):
290 291 292 293 294 295 296 297 298 299 300 301 302 303
        msg = _(
            "Sorry! Due to an error your purchase was charged for "
            "a different amount than the order total! "
            "The specific error message is: {error_message}. "
            "Your credit card has probably been charged. "
            "Contact us with payment-specific questions at {email}."
        )
        formatted = msg.format(
            error_message='<span class="exception_msg">{msg}</span>'.format(
                msg=exception.message,
            ),
            email=payment_support_email,
        )
        return '<p class="error_msg">{}</p>'.format(formatted)
304
    elif isinstance(exception, CCProcessorSignatureException):
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321
        msg = _(
            "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. "
            "The specific error message is: {error_message}. "
            "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}."
        )
        formatted = msg.format(
            error_message='<span class="exception_msg">{msg}</span>'.format(
                msg=exception.message,
            ),
            email=payment_support_email,
        )
        return '<p class="error_msg">{}</p>'.format(formatted)
322 323 324

    # fallthrough case, which basically never happens
    return '<p class="error_msg">EXCEPTION!</p>'
325

326

Diana Huang committed
327
CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN")
328
CARDTYPE_MAP.update(
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349
    {
        '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',
        '043': 'GE Money UK card'
    }
)
350

Diana Huang committed
351
REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON")
352 353
REASONCODE_MAP.update(
    {
Diana Huang committed
354 355 356
        '100': _('Successful transaction.'),
        '101': _('The request is missing one or more required fields.'),
        '102': _('One or more fields in the request contains invalid data.'),
357
        '104': dedent(_(
358 359 360 361 362
            """
            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.
            """)),
Diana Huang committed
363
        '150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'),
364
        '151': dedent(_(
365 366 367 368 369
            """
            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.
            """)),
370
        '152': dedent(_(
371 372 373 374
            """
            Error: The request was received, but a service did not finish running in time
            Possible fix: retry the payment after some time.
            """)),
Diana Huang committed
375
        '201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'),
376
        '202': dedent(_(
377 378 379 380 381
            """
            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
            """)),
382
        '203': dedent(_(
383 384 385 386
            """
            General decline of the card. No other information provided by the issuing bank.
            Possible fix: retry with another form of payment
            """)),
Diana Huang committed
387
        '204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'),
388
        # 205 was Stolen or lost card.  Might as well not show this message to the person using such a card.
Diana Huang committed
389 390
        '205': _('Unknown reason'),
        '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'),
391
        '208': dedent(_(
392 393 394 395
            """
            Inactive card or card not authorized for card-not-present transactions.
            Possible fix: retry with another form of payment
            """)),
Diana Huang committed
396 397
        '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'),
398 399
        # 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.
Diana Huang committed
400 401
        '221': _('Unknown reason'),
        '231': _('Invalid account number. Possible fix: retry with another form of payment'),
402
        '232': dedent(_(
403 404 405 406
            """
            The card type is not accepted by the payment processor.
            Possible fix: retry with another form of payment
            """)),
Diana Huang committed
407
        '233': _('General decline by the processor.  Possible fix: retry with another form of payment'),
408 409 410
        '234': _(
            "There is a problem with our CyberSource merchant configuration.  Please let us know at {0}"
        ).format(settings.PAYMENT_SUPPORT_EMAIL),
411
        # reason code 235 only applies if we are processing a capture through the API. so we should never see it
Diana Huang committed
412 413
        '235': _('The requested amount exceeds the originally authorized amount.'),
        '236': _('Processor Failure.  Possible fix: retry the payment'),
414
        # reason code 238 only applies if we are processing a capture through the API. so we should never see it
Diana Huang committed
415
        '238': _('The authorization has already been captured'),
416 417
        # reason code 239 only applies if we are processing a capture or credit through the API,
        # so we should never see it
Diana Huang committed
418
        '239': _('The requested transaction amount must match the previous transaction amount.'),
419
        '240': dedent(_(
420 421 422 423 424 425
            """
            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
Diana Huang committed
426
        '241': _('The request ID is invalid.'),
427 428 429 430
        # 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
431
        '242': dedent(_(
432 433 434 435
            """
            You requested a capture through the API, but there is no corresponding, unused authorization record.
            """)),
        # we should never see 243
Diana Huang committed
436
        '243': _('The transaction has already been settled or reversed.'),
437
        # reason code 246 applies only if we are processing a void through the API. so we should never see it
438
        '246': dedent(_(
439 440 441 442 443
            """
            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
Diana Huang committed
444
        '247': _('You requested a credit for a capture that was previously voided'),
445
        '250': dedent(_(
446 447 448 449
            """
            Error: The request was received, but there was a timeout at the payment processor.
            Possible fix: retry the payment.
            """)),
450
        '520': dedent(_(
451 452 453 454 455
            """
            The authorization request was approved by the issuing bank but declined by CyberSource.'
            Possible fix: retry with a different form of payment.
            """)),
    }
Diana Huang committed
456
)