Commit e4e22f0f by Jason Bau Committed by Diana Huang

Lots more verification of CyberSource reply + receipt generation

parent 41b9f9f0
# -*- 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']
\ No newline at end of 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'
......
### 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,
})
\ No newline at end of file
})
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'
}
)
......@@ -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)
class PaymentException(Exception):
pass
class CCProcessorException(PaymentException):
pass
class CCProcessorDataException(CCProcessorException):
pass
class CCProcessorWrongAmountException(PaymentException):
pass
\ No newline at end of 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'),
)
\ No newline at end of file
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
HANDLES THE ACCEPT AND REVIEW CASES
"""
if verify(request.POST.dict()):
return HttpResponse("Validated")
# 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("Not Validated")
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
"""
......@@ -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']
......
......@@ -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' : {
......
......@@ -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>
......
<%! 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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment