diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py new file mode 100644 index 0000000000..85923794b6 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 0bf4e3934e..052ecfb888 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -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' diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 98026e6a84..17e1511ac6 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -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, - }) \ 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' + } +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index de567976be..520c353535 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -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) + diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py new file mode 100644 index 0000000000..bc132a3d54 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -0,0 +1,11 @@ +class PaymentException(Exception): + pass + +class CCProcessorException(PaymentException): + pass + +class CCProcessorDataException(CCProcessorException): + pass + +class CCProcessorWrongAmountException(PaymentException): + pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 99d5217813..58e51f0b40 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -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[0-9]*)/$', 'show_receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 00e6db0e7d..f5540aafbb 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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 + """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f7c6db39f9..f6eb45ec51 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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'] diff --git a/lms/envs/common.py b/lms/envs/common.py index d066259fe5..7e4d23f065 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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' : { diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 35623d8b5b..677077ba2d 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,10 +7,11 @@ <%block name="title">${_("Your Shopping Cart")}
+

${_("Your selected items:")}

% if shoppingcart_items: - + ${_("")} % for item in shoppingcart_items: @@ -19,7 +20,7 @@ % endfor - + @@ -27,7 +28,7 @@ ${form_html} % else: -

You have selected no items for purchase.

+

${_("You have selected no items for purchase.")}

% endif diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html new file mode 100644 index 0000000000..01de3b3f83 --- /dev/null +++ b/lms/templates/shoppingcart/receipt.html @@ -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">${_("Receipt for Order")} ${order.id} + + + +
+

${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

+

${_("Order #")}${order.id}

+

${_("Date:")} ${order.purchase_time.date().isoformat()}

+

${_("Items ordered:")}

+ +
QtyDescriptionUnit PricePriceCurrency
QuantityDescriptionUnit PricePriceCurrency
${item.currency.upper()} [x]
Total Amount
${_("Total Amount")}
${amount}
+ + ${_("")} + + + % for item in order_items: + + % if item.status == "purchased": + + + + + % elif item.status == "refunded": + + + + + % endif + % endfor + + + +
QtyDescriptionUnit PricePriceCurrency
${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
${_("Total Amount")}
${"{0:0.2f}".format(order.total_cost)}
+ % if any_refunds: +

+ ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} +

+ % endif + +

${_("Billed To:")}

+

+ ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
+ ${order.bill_to_first} ${order.bill_to_last}
+ ${order.bill_to_street1}
+ ${order.bill_to_street2}
+ ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
+ ${order.bill_to_country.upper()}
+

+ +