Lots more verification of CyberSource reply + receipt generation

This commit is contained in:
Jason Bau
2013-08-09 18:36:35 -07:00
committed by Diana Huang
parent 41b9f9f071
commit e4e22f0f85
11 changed files with 365 additions and 19 deletions

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'Order.bill_to_state'
db.add_column('shoppingcart_order', 'bill_to_state',
self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'Order.bill_to_state'
db.delete_column('shoppingcart_order', 'bill_to_state')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'shoppingcart.order': {
'Meta': {'object_name': 'Order'},
'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}),
'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}),
'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}),
'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}),
'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}),
'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.orderitem': {
'Meta': {'object_name': 'OrderItem'},
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}),
'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}),
'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.paidcourseregistration': {
'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
}
}
complete_apps = ['shoppingcart']

View File

@@ -32,6 +32,7 @@ class Order(models.Model):
bill_to_street1 = models.CharField(max_length=128, null=True, blank=True)
bill_to_street2 = models.CharField(max_length=128, null=True, blank=True)
bill_to_city = models.CharField(max_length=64, null=True, blank=True)
bill_to_state = models.CharField(max_length=8, null=True, blank=True)
bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True)
bill_to_country = models.CharField(max_length=64, null=True, blank=True)
bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits
@@ -49,7 +50,7 @@ class Order(models.Model):
@property
def total_cost(self):
return sum([i.line_cost for i in self.orderitem_set.all()])
return sum([i.line_cost for i in self.orderitem_set.filter(status=self.status)])
@property
def currency(self):
@@ -60,13 +61,25 @@ class Order(models.Model):
else:
return items[0].currency
def purchase(self):
def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='',
country='', ccnum='', cardtype='', processor_reply_dump=''):
"""
Call to mark this order as purchased. Iterates through its OrderItems and calls
their purchased_callback
"""
self.status = 'purchased'
self.purchase_time = datetime.now(pytz.utc)
self.bill_to_first = first
self.bill_to_last = last
self.bill_to_street1 = street1
self.bill_to_street2 = street2
self.bill_to_city = city
self.bill_to_state = state
self.bill_to_postalcode = postalcode
self.bill_to_country = country
self.bill_to_ccnum = ccnum
self.bill_to_cardtype = cardtype
self.processor_reply_dump = processor_reply_dump
self.save()
for item in self.orderitem_set.all():
item.status = 'purchased'

View File

@@ -1,13 +1,19 @@
### 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
import time
import hmac
import binascii
from collections import OrderedDict
import re
import json
from collections import OrderedDict, defaultdict
from hashlib import sha1
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 CCProcessorDataException, CCProcessorWrongAmountException
shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','')
merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','')
@@ -41,6 +47,7 @@ def sign(params):
return params
def verify(params):
"""
Verify the signatures accompanying the POST back from Cybersource Hosted Order Page
@@ -54,7 +61,11 @@ def verify(params):
return False
return hash(data) == returned_sig
def render_purchase_form_html(cart, user):
"""
Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource
"""
total_cost = cart.total_cost
amount = "{0:0.2f}".format(total_cost)
cart_items = cart.orderitem_set.all()
@@ -64,7 +75,6 @@ def render_purchase_form_html(cart, user):
params['currency'] = cart.currency
params['orderPage_transactionType'] = 'sale'
params['orderNumber'] = "{0:d}".format(cart.id)
params['billTo_email'] = user.email
idx=1
for item in cart_items:
prefix = "item_{0:d}_".format(idx)
@@ -78,4 +88,100 @@ def render_purchase_form_html(cart, user):
return render_to_string('shoppingcart/cybersource_form.html', {
'action': purchase_endpoint,
'params': signed_param_dict,
})
})
def payment_accepted(params):
"""
Check that cybersource has accepted the payment
"""
#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:
raise CCProcessorDataException(
_("The payment processor did not return a required parameter: {0}".format(key))
)
try:
valid_params[key] = type(params[key])
except ValueError:
raise CCProcessorDataException(
_("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key))
)
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':
if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency:
return {'accepted': True,
'amt_charged': valid_params['ccAuthReply_amount'],
'currency': valid_params['orderCurrency'],
'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(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'],
order.total_cost, order.currency))
)
else:
return {'accepted': False,
'amt_charged': 0,
'currency': 'usd',
'order': None}
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', ''),
postalcode=params.get('billTo_postalCode',''),
ccnum=ccnum,
cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')],
processor_reply_dump=json.dumps(params)
)
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',
'043': 'GE Money UK card'
}
)

View File

@@ -3,7 +3,12 @@ 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=['sign', 'verify', 'render_purchase_form_html'])
fromlist=['sign',
'verify',
'render_purchase_form_html'
'payment_accepted',
'record_purchase',
])
def sign(*args, **kwargs):
"""
@@ -32,3 +37,18 @@ def render_purchase_form_html(*args, **kwargs):
Returns the HTML as a string
"""
return module.render_purchase_form_html(*args, **kwargs)
def payment_accepted(*args, **kwargs):
"""
Given params returned by the CC processor, check that processor has accepted the payment
Returns a dict of {accepted:bool, amt_charged:float, currency:str, order:Order}
"""
return module.payment_accepted(*args, **kwargs)
def record_purchase(*args, **kwargs):
"""
Given params returned by the CC processor, record that the purchase has occurred in
the database and also run callbacks
"""
return module.record_purchase(*args, **kwargs)

View File

@@ -0,0 +1,11 @@
class PaymentException(Exception):
pass
class CCProcessorException(PaymentException):
pass
class CCProcessorDataException(CCProcessorException):
pass
class CCProcessorWrongAmountException(PaymentException):
pass

View File

@@ -7,5 +7,6 @@ urlpatterns = patterns('shoppingcart.views', # nopep8
url(r'^clear/$','clear_cart'),
url(r'^remove_item/$', 'remove_item'),
url(r'^purchased/$', 'purchased'),
url(r'^receipt/$', 'receipt'),
url(r'^postpay_accept_callback/$', 'postpay_accept_callback'),
url(r'^receipt/(?P<ordernum>[0-9]*)/$', 'show_receipt'),
)

View File

@@ -1,11 +1,13 @@
import logging
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.core.urlresolvers import reverse
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response
from .models import *
from .processors import verify, render_purchase_form_html
from .processors import verify, payment_accepted, render_purchase_form_html, record_purchase
from .processors.exceptions import CCProcessorDataException, CCProcessorWrongAmountException
log = logging.getLogger("shoppingcart")
@@ -60,13 +62,52 @@ def remove_item(request):
return HttpResponse('OK')
@csrf_exempt
def receipt(request):
def postpay_accept_callback(request):
"""
Receives the POST-back from processor and performs the validation and displays a receipt
and does some other stuff
"""
if verify(request.POST.dict()):
return HttpResponse("Validated")
else:
return HttpResponse("Not Validated")
HANDLES THE ACCEPT AND REVIEW CASES
"""
# TODO: Templates and logic for all error cases and the REVIEW CASE
params = request.POST.dict()
if verify(params):
try:
result = payment_accepted(params)
if result['accepted']:
# ACCEPTED CASE first
record_purchase(params, result['order'])
#render_receipt
return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id]))
else:
return HttpResponse("CC Processor has not accepted the payment.")
except CCProcessorWrongAmountException:
return HttpResponse("Charged the wrong amount, contact our user support")
except CCProcessorDataException:
return HttpResponse("Exception: the processor returned invalid data")
else:
return HttpResponse("There has been a communication problem blah blah. Not Validated")
def show_receipt(request, ordernum):
"""
Displays a receipt for a particular order.
404 if order is not yet purchased or request.user != order.user
"""
try:
order = Order.objects.get(id=ordernum)
except Order.DoesNotExist:
raise Http404('Order not found!')
if order.user != request.user or order.status != 'purchased':
raise Http404('Order not found!')
order_items = order.orderitem_set.all()
any_refunds = "refunded" in [i.status for i in order_items]
return render_to_response('shoppingcart/receipt.html', {'order': order,
'order_items': order_items,
'any_refunds': any_refunds})
def show_orders(request):
"""
Displays all orders of a user
"""

View File

@@ -127,6 +127,7 @@ SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL)
BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL)
PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_EMAIL)
#Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
@@ -190,7 +191,6 @@ SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY')
if SEGMENT_IO_LMS_KEY:
MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False)
CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR)
SECRET_KEY = AUTH_TOKENS['SECRET_KEY']

View File

@@ -432,6 +432,7 @@ ZENDESK_USER = None
ZENDESK_API_KEY = None
##### shoppingcart Payment #####
PAYMENT_SUPPORT_EMAIL = 'payment@edx.org'
##### Using cybersource by default #####
CC_PROCESSOR = {
'CyberSource' : {

View File

@@ -7,10 +7,11 @@
<%block name="title"><title>${_("Your Shopping Cart")}</title></%block>
<section class="container cart-list">
<h2>${_("Your selected items:")}</h2>
% if shoppingcart_items:
<table>
<thead>
<tr><td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td></tr>
<tr>${_("<td>Quantity</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
</thead>
<tbody>
% for item in shoppingcart_items:
@@ -19,7 +20,7 @@
<td>${item.currency.upper()}</td>
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr>
% endfor
<tr><td></td><td></td><td></td><td>Total Amount</td></tr>
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
<tr><td></td><td></td><td></td><td>${amount}</td></tr>
</tbody>
@@ -27,7 +28,7 @@
${form_html}
% else:
<p>You have selected no items for purchase.</p>
<p>${_("You have selected no items for purchase.")}</p>
% endif
</section>

View File

@@ -0,0 +1,56 @@
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%! from django.conf import settings %>
<%inherit file="../main.html" />
<%block name="title"><title>${_("Receipt for Order")} ${order.id}</title></%block>
<section class="container cart-list">
<p><h1>${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h1></p>
<h2>${_("Order #")}${order.id}</h2>
<h2>${_("Date:")} ${order.purchase_time.date().isoformat()}</h2>
<h2>${_("Items ordered:")}</h2>
<table>
<thead>
<tr>${_("<td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
</thead>
<tbody>
% for item in order_items:
<tr>
% if item.status == "purchased":
<td>${item.qty}</td><td>${item.line_desc}</td>
<td>${"{0:0.2f}".format(item.unit_cost)}</td>
<td>${"{0:0.2f}".format(item.line_cost)}</td>
<td>${item.currency.upper()}</td></tr>
% elif item.status == "refunded":
<td><del>${item.qty}</del></td><td><del>${item.line_desc}</del></td>
<td><del>${"{0:0.2f}".format(item.unit_cost)}</del></td>
<td><del>${"{0:0.2f}".format(item.line_cost)}</del></td>
<td><del>${item.currency.upper()}</del></td></tr>
% endif
% endfor
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(order.total_cost)}</td></tr>
</tbody>
</table>
% if any_refunds:
<p>
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
</p>
% endif
<h2>${_("Billed To:")}</h2>
<p>
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
${order.bill_to_first} ${order.bill_to_last}<br />
${order.bill_to_street1}<br />
${order.bill_to_street2}<br />
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
${order.bill_to_country.upper()}<br />
</p>
</section>