diff --git a/.coveragerc b/.coveragerc index 92747ee..619746d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,4 @@ [run] include = authorizenet/* -omit = authorizenet/tests/* +omit = tests/* branch = 1 diff --git a/authorizenet/admin.py b/authorizenet/admin.py index cb3c5fb..2b327b3 100644 --- a/authorizenet/admin.py +++ b/authorizenet/admin.py @@ -1,7 +1,9 @@ from django.contrib import admin from django.core.urlresolvers import reverse from django.utils.safestring import mark_safe -from authorizenet.models import Response, CIMResponse +from authorizenet.models import (Response, CIMResponse, CustomerProfile, + CustomerPaymentProfile) +from authorizenet.forms import CustomerPaymentForm, CustomerPaymentAdminForm class ResponseAdmin(admin.ModelAdmin): @@ -78,3 +80,32 @@ def response_link(self, obj): response_link.short_description = 'transaction response' admin.site.register(CIMResponse, CIMResponseAdmin) + + +class CustomerPaymentProfileInline(admin.StackedInline): + model = CustomerPaymentProfile + extra = 0 + max_num = 0 + form = CustomerPaymentForm + + +class CustomerProfileAdmin(admin.ModelAdmin): + list_display = ['profile_id', 'customer'] + readonly_fields = ['profile_id', 'customer'] + inlines = [CustomerPaymentProfileInline] + + def get_readonly_fields(self, request, obj=None): + return self.readonly_fields if obj is not None else ['profile_id'] + +admin.site.register(CustomerProfile, CustomerProfileAdmin) + + +class CustomerPaymentProfileAdmin(admin.ModelAdmin): + list_display = ['payment_profile_id', 'customer_profile', 'customer'] + readonly_fields = ['payment_profile_id', 'customer', 'customer_profile'] + form = CustomerPaymentAdminForm + + def get_readonly_fields(self, request, obj=None): + return self.readonly_fields if obj is not None else [] + +admin.site.register(CustomerPaymentProfile, CustomerPaymentProfileAdmin) diff --git a/authorizenet/cim.py b/authorizenet/cim.py index 0da2a65..07ecae6 100644 --- a/authorizenet/cim.py +++ b/authorizenet/cim.py @@ -2,11 +2,10 @@ import xml.dom.minidom from django.utils.datastructures import SortedDict -from django.conf import settings +from authorizenet.conf import settings import requests from authorizenet import AUTHNET_CIM_URL, AUTHNET_TEST_CIM_URL -from authorizenet.models import CIMResponse, Response from authorizenet.signals import customer_was_created, customer_was_flagged, \ payment_was_successful, payment_was_flagged @@ -48,8 +47,9 @@ def extract_form_data(data): def extract_payment_form_data(data): payment_data = extract_form_data(data) - payment_data['expirationDate'] = \ - payment_data['expirationDate'].strftime('%Y-%m') + if payment_data.get('expirationDate') is not None: + payment_data['expirationDate'] = \ + payment_data['expirationDate'].strftime('%Y-%m') return payment_data @@ -113,6 +113,18 @@ def add_profile(customer_id, payment_form_data, billing_form_data, 'shipping_profile_ids': shipping_profile_ids} +def delete_profile(profile_id): + """ + Delete a customer profile and return the CIMResponse. + + Arguments: + profile_id -- unique gateway-assigned profile identifier + """ + helper = DeleteProfileRequest(profile_id) + response = helper.get_response() + return response + + def update_payment_profile(profile_id, payment_profile_id, payment_form_data, @@ -265,7 +277,7 @@ class BaseRequest(object): def __init__(self, action): self.create_base_document(action) - if settings.AUTHNET_DEBUG: + if settings.DEBUG: self.endpoint = AUTHNET_TEST_CIM_URL else: self.endpoint = AUTHNET_CIM_URL @@ -285,9 +297,9 @@ def create_base_document(self, action): self.document = doc authentication = doc.createElement("merchantAuthentication") - name = self.get_text_node("name", settings.AUTHNET_LOGIN_ID) + name = self.get_text_node("name", settings.LOGIN_ID) key = self.get_text_node("transactionKey", - settings.AUTHNET_TRANSACTION_KEY) + settings.TRANSACTION_KEY) authentication.appendChild(name) authentication.appendChild(key) root.appendChild(authentication) @@ -303,7 +315,8 @@ def get_response(self): self.endpoint, data=self.document.toxml().encode('utf-8'), headers={'Content-Type': 'text/xml'}) - response_xml = xml.dom.minidom.parseString(response.text) + text = response.text.encode('utf-8') + response_xml = xml.dom.minidom.parseString(text) self.process_response(response_xml) return self.create_response_object() @@ -317,6 +330,7 @@ def get_text_node(self, node_name, text): return node def create_response_object(self): + from authorizenet.models import CIMResponse return CIMResponse.objects.create(result=self.result, result_code=self.resultCode, result_text=self.resultText) @@ -694,7 +708,7 @@ def __init__(self, if delimiter: self.delimiter = delimiter else: - self.delimiter = getattr(settings, 'AUTHNET_DELIM_CHAR', "|") + self.delimiter = settings.DELIM_CHAR self.add_transaction_node() self.add_extra_options() if order_info: @@ -755,6 +769,7 @@ def add_extra_options(self): self.root.appendChild(extra_options_node) def create_response_object(self): + from authorizenet.models import CIMResponse, Response try: response = Response.objects.create_from_list( self.transaction_result) diff --git a/authorizenet/conf.py b/authorizenet/conf.py new file mode 100644 index 0000000..45d982a --- /dev/null +++ b/authorizenet/conf.py @@ -0,0 +1,39 @@ +"""Application-specific settings for django-authorizenet""" + +from django.conf import settings as django_settings + + +class Settings(object): + + """ + Retrieves django.conf settings, using defaults from Default subclass + + All usable settings are specified in settings attribute. Use an + ``AUTHNET_`` prefix when specifying settings in django.conf. + """ + + prefix = 'AUTHNET_' + settings = set(('DEBUG', 'LOGIN_ID', 'TRANSACTION_KEY', 'CUSTOMER_MODEL', + 'DELIM_CHAR', 'FORCE_TEST_REQUEST', 'EMAIL_CUSTOMER', + 'MD5_HASH')) + + class Default: + CUSTOMER_MODEL = getattr( + django_settings, 'AUTH_USER_MODEL', "auth.User") + DELIM_CHAR = "|" + FORCE_TEST_REQUEST = False + EMAIL_CUSTOMER = None + MD5_HASH = "" + + def __init__(self): + self.defaults = Settings.Default() + + def __getattr__(self, name): + if name not in self.settings: + raise AttributeError("Setting %s not understood" % name) + try: + return getattr(django_settings, self.prefix + name) + except AttributeError: + return getattr(self.defaults, name) + +settings = Settings() diff --git a/authorizenet/exceptions.py b/authorizenet/exceptions.py new file mode 100644 index 0000000..a572ba7 --- /dev/null +++ b/authorizenet/exceptions.py @@ -0,0 +1,2 @@ +class BillingError(Exception): + """Error due to Authorize.NET request""" diff --git a/authorizenet/fields.py b/authorizenet/fields.py index 2fd4a63..d0b5f7b 100644 --- a/authorizenet/fields.py +++ b/authorizenet/fields.py @@ -4,9 +4,9 @@ from calendar import monthrange from django import forms -from django.conf import settings from django.utils.translation import ugettext as _ +from authorizenet.conf import settings from authorizenet.creditcard import verify_credit_card @@ -23,8 +23,7 @@ def clean(self, value): Raises a ValidationError if the card is not valid and stashes card type. """ - self.card_type = verify_credit_card(value, - allow_test=settings.AUTHNET_DEBUG) + self.card_type = verify_credit_card(value, allow_test=settings.DEBUG) if self.card_type is None: raise forms.ValidationError("Invalid credit card number.") return value @@ -98,6 +97,7 @@ def compress(self, data_list): class CreditCardCVV2Field(forms.CharField): def __init__(self, *args, **kwargs): + kwargs.setdefault('min_length', 3) kwargs.setdefault('max_length', 4) super(CreditCardCVV2Field, self).__init__(*args, **kwargs) diff --git a/authorizenet/forms.py b/authorizenet/forms.py index 648bb82..61c347d 100644 --- a/authorizenet/forms.py +++ b/authorizenet/forms.py @@ -1,14 +1,15 @@ from django import forms -from django.conf import settings +from authorizenet.conf import settings from authorizenet.fields import CreditCardField, CreditCardExpiryField, \ CreditCardCVV2Field, CountryField +from authorizenet.models import CustomerPaymentProfile class SIMPaymentForm(forms.Form): x_login = forms.CharField(max_length=20, required=True, widget=forms.HiddenInput, - initial=settings.AUTHNET_LOGIN_ID) + initial=settings.LOGIN_ID) x_type = forms.CharField(max_length=20, widget=forms.HiddenInput, initial="AUTH_CAPTURE") @@ -91,12 +92,46 @@ class CIMPaymentForm(forms.Form): card_code = CreditCardCVV2Field(label="Card Security Code") +class CustomerPaymentForm(forms.ModelForm): + + """Base customer payment form without shipping address""" + + country = CountryField(label="Country", initial="US") + card_number = CreditCardField(label="Credit Card Number") + expiration_date = CreditCardExpiryField(label="Expiration Date") + card_code = CreditCardCVV2Field(label="Card Security Code") + + def __init__(self, *args, **kwargs): + self.customer = kwargs.pop('customer', None) + return super(CustomerPaymentForm, self).__init__(*args, **kwargs) + + def save(self, commit=True): + instance = super(CustomerPaymentForm, self).save(commit=False) + if self.customer: + instance.customer = self.customer + instance.card_code = self.cleaned_data.get('card_code') + if commit: + instance.save() + return instance + + class Meta: + model = CustomerPaymentProfile + fields = ('first_name', 'last_name', 'company', 'address', 'city', + 'state', 'country', 'zip', 'card_number', + 'expiration_date', 'card_code') + + +class CustomerPaymentAdminForm(CustomerPaymentForm): + class Meta(CustomerPaymentForm.Meta): + fields = ('customer',) + CustomerPaymentForm.Meta.fields + + class HostedCIMProfileForm(forms.Form): token = forms.CharField(widget=forms.HiddenInput) def __init__(self, token, *args, **kwargs): super(HostedCIMProfileForm, self).__init__(*args, **kwargs) self.fields['token'].initial = token - if settings.AUTHNET_DEBUG: + if settings.DEBUG: self.action = "https://test.authorize.net/profile/manage" else: self.action = "https://secure.authorize.net/profile/manage" diff --git a/authorizenet/helpers.py b/authorizenet/helpers.py index ae63897..835af71 100644 --- a/authorizenet/helpers.py +++ b/authorizenet/helpers.py @@ -1,15 +1,15 @@ import re -from django.conf import settings import requests +from authorizenet.conf import settings from authorizenet import AUTHNET_POST_URL, AUTHNET_TEST_POST_URL class AIMPaymentHelper(object): def __init__(self, defaults): self.defaults = defaults - if settings.AUTHNET_DEBUG: + if settings.DEBUG: self.endpoint = AUTHNET_TEST_POST_URL else: self.endpoint = AUTHNET_POST_URL diff --git a/authorizenet/managers.py b/authorizenet/managers.py new file mode 100644 index 0000000..384d92d --- /dev/null +++ b/authorizenet/managers.py @@ -0,0 +1,34 @@ +from django.db import models + + +class CustomerProfileManager(models.Manager): + + def create(self, **data): + + """Create new Authorize.NET customer profile""" + + from .models import CustomerPaymentProfile + + kwargs = data + sync = kwargs.pop('sync', True) + kwargs = { + 'customer': kwargs.get('customer', None), + 'profile_id': kwargs.pop('profile_id', None), + } + + # Create customer profile + obj = self.model(**kwargs) + self._for_write = True + obj.save(force_insert=True, using=self.db, sync=sync, data=data) + + if sync: + # Store customer payment profile data locally + for payment_profile_id in obj.payment_profile_ids: + CustomerPaymentProfile.objects.create( + customer_profile=obj, + payment_profile_id=payment_profile_id, + sync=False, + **data + ) + + return obj diff --git a/authorizenet/migrations/0005_auto__add_customerpaymentprofile__add_customerprofile__chg_field_cimre.py b/authorizenet/migrations/0005_auto__add_customerpaymentprofile__add_customerprofile__chg_field_cimre.py new file mode 100644 index 0000000..1bfae2d --- /dev/null +++ b/authorizenet/migrations/0005_auto__add_customerpaymentprofile__add_customerprofile__chg_field_cimre.py @@ -0,0 +1,220 @@ +# -*- 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 model 'CustomerPaymentProfile' + db.create_table(u'authorizenet_customerpaymentprofile', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('customer', self.gf('django.db.models.fields.related.ForeignKey')(related_name='payment_profiles', to=orm['doctors.Practice'])), + ('customer_profile', self.gf('django.db.models.fields.related.ForeignKey')(related_name='payment_profiles', to=orm['authorizenet.CustomerProfile'])), + ('payment_profile_id', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('first_name', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('company', self.gf('django.db.models.fields.CharField')(max_length=60, blank=True)), + ('phone_number', self.gf('django.db.models.fields.CharField')(max_length=25, blank=True)), + ('fax_number', self.gf('django.db.models.fields.CharField')(max_length=25, blank=True)), + ('address', self.gf('django.db.models.fields.CharField')(max_length=60, blank=True)), + ('city', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('state', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('zip', self.gf('django.db.models.fields.CharField')(max_length=20, blank=True)), + ('country', self.gf('django.db.models.fields.CharField')(max_length=60, blank=True)), + ('card_number', self.gf('django.db.models.fields.CharField')(max_length=16, blank=True)), + ('expiration_date', self.gf('django.db.models.fields.DateField')(null=True, blank=True)), + )) + db.send_create_signal(u'authorizenet', ['CustomerPaymentProfile']) + + # Adding model 'CustomerProfile' + db.create_table(u'authorizenet_customerprofile', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('customer', self.gf('django.db.models.fields.related.OneToOneField')(related_name='customer_profile', unique=True, to=orm['doctors.Practice'])), + ('profile_id', self.gf('django.db.models.fields.CharField')(max_length=50)), + )) + db.send_create_signal(u'authorizenet', ['CustomerProfile']) + + + # Changing field 'CIMResponse.result_text' + db.alter_column(u'authorizenet_cimresponse', 'result_text', self.gf('django.db.models.fields.TextField')()) + + def backwards(self, orm): + # Deleting model 'CustomerPaymentProfile' + db.delete_table(u'authorizenet_customerpaymentprofile') + + # Deleting model 'CustomerProfile' + db.delete_table(u'authorizenet_customerprofile') + + + # Changing field 'CIMResponse.result_text' + db.alter_column(u'authorizenet_cimresponse', 'result_text', self.gf('django.db.models.fields.TextField')(max_length=1023)) + + models = { + u'authorizenet.cimresponse': { + 'Meta': {'object_name': 'CIMResponse'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'result': ('django.db.models.fields.CharField', [], {'max_length': '8'}), + 'result_code': ('django.db.models.fields.CharField', [], {'max_length': '8'}), + 'result_text': ('django.db.models.fields.TextField', [], {}), + 'transaction_response': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['authorizenet.Response']", 'null': 'True', 'blank': 'True'}) + }, + u'authorizenet.customerpaymentprofile': { + 'Meta': {'object_name': 'CustomerPaymentProfile'}, + 'address': ('django.db.models.fields.CharField', [], {'max_length': '60', 'blank': 'True'}), + 'card_number': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'company': ('django.db.models.fields.CharField', [], {'max_length': '60', 'blank': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '60', 'blank': 'True'}), + 'customer': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'payment_profiles'", 'to': u"orm['doctors.Practice']"}), + 'customer_profile': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'payment_profiles'", 'to': u"orm['authorizenet.CustomerProfile']"}), + 'expiration_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'fax_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'payment_profile_id': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}) + }, + u'authorizenet.customerprofile': { + 'Meta': {'object_name': 'CustomerProfile'}, + 'customer': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'customer_profile'", 'unique': 'True', 'to': u"orm['doctors.Practice']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'profile_id': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'authorizenet.response': { + 'MD5_Hash': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'Meta': {'object_name': 'Response'}, + 'account_number': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '10', 'blank': 'True'}), + 'address': ('django.db.models.fields.CharField', [], {'max_length': '60'}), + 'amount': ('django.db.models.fields.CharField', [], {'max_length': '16'}), + 'auth_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}), + 'avs_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}), + 'card_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '10', 'blank': 'True'}), + 'cavv_response': ('django.db.models.fields.CharField', [], {'max_length': '2', 'blank': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'company': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '60'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}), + 'cust_id': ('django.db.models.fields.CharField', [], {'max_length': '20'}), + 'cvv2_resp_code': ('django.db.models.fields.CharField', [], {'max_length': '2', 'blank': 'True'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'duty': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '25'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'freight': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice_num': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'method': ('django.db.models.fields.CharField', [], {'max_length': '10'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '25'}), + 'po_num': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}), + 'response_code': ('django.db.models.fields.CharField', [], {'max_length': '2'}), + 'response_reason_code': ('django.db.models.fields.CharField', [], {'max_length': '15'}), + 'response_reason_text': ('django.db.models.fields.TextField', [], {}), + 'response_subcode': ('django.db.models.fields.CharField', [], {'max_length': '10'}), + 'ship_to_address': ('django.db.models.fields.CharField', [], {'max_length': '60', 'blank': 'True'}), + 'ship_to_city': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'ship_to_company': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'ship_to_country': ('django.db.models.fields.CharField', [], {'max_length': '60', 'blank': 'True'}), + 'ship_to_first_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'ship_to_last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'ship_to_state': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'ship_to_zip': ('django.db.models.fields.CharField', [], {'max_length': '20', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'tax': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'tax_exempt': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'test_request': ('django.db.models.fields.CharField', [], {'default': "'FALSE'", 'max_length': '10', 'blank': 'True'}), + 'trans_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'type': ('django.db.models.fields.CharField', [], {'max_length': '20', 'db_index': 'True'}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, + u'base.address': { + 'Meta': {'object_name': 'Address'}, + 'address1': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'address2': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'state': ('django_localflavor_us.models.USStateField', [], {'max_length': '2', 'blank': 'True'}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '10', 'blank': 'True'}) + }, + u'doctors.employeetype': { + 'Meta': {'object_name': 'EmployeeType'}, + 'has_profile': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_doctor': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_schedulable': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}) + }, + u'doctors.practice': { + 'Meta': {'object_name': 'Practice'}, + 'accepted_insurance': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['medical.InsurancePlan']", 'symmetrical': 'False', 'blank': 'True'}), + 'addresses': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['base.Address']", 'through': u"orm['doctors.PracticeAddress']", 'symmetrical': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'phone': ('base.fields.PhoneNumberField', [], {'max_length': '20'}), + 'practice_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['doctors.PracticeType']"}), + 'statement': ('django.db.models.fields.TextField', [], {'max_length': '5000', 'blank': 'True'}), + 'timezone': ('timezone_field.fields.TimeZoneField', [], {}) + }, + u'doctors.practiceaddress': { + 'Meta': {'object_name': 'PracticeAddress'}, + 'address': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['base.Address']", 'unique': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'practice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['doctors.Practice']"}) + }, + u'doctors.practiceemployeetype': { + 'Meta': {'ordering': "['order']", 'object_name': 'PracticeEmployeeType', 'db_table': "'doctors_practicetype_employee_types'"}, + 'employeetype': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['doctors.EmployeeType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), + 'practicetype': ('adminsortable.fields.SortableForeignKey', [], {'to': u"orm['doctors.PracticeType']"}) + }, + u'doctors.practicetype': { + 'Meta': {'ordering': "['order']", 'object_name': 'PracticeType'}, + 'employee_types': ('sortedm2m.fields.SortedManyToManyField', [], {'to': u"orm['doctors.EmployeeType']", 'through': u"orm['doctors.PracticeEmployeeType']", 'symmetrical': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), + 'specialties': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['medical.DoctorSpecialty']", 'symmetrical': 'False'}) + }, + u'medical.appointmenttype': { + 'Meta': {'ordering': "['order']", 'object_name': 'AppointmentType'}, + 'category': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['medical.BillingCategory']", 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), + 'specialty': ('adminsortable.fields.SortableForeignKey', [], {'related_name': "'appointment_types'", 'to': u"orm['medical.DoctorSpecialty']"}) + }, + u'medical.billingcategory': { + 'Meta': {'object_name': 'BillingCategory'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'medical.doctorspecialty': { + 'Meta': {'ordering': "['order']", 'object_name': 'DoctorSpecialty'}, + 'default_appointment_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['medical.AppointmentType']", 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}) + }, + u'medical.insuranceplan': { + 'Meta': {'ordering': "['provider__name', 'name']", 'object_name': 'InsurancePlan'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'plans'", 'to': u"orm['medical.InsuranceProvider']"}) + }, + u'medical.insuranceprovider': { + 'Meta': {'ordering': "['name']", 'object_name': 'InsuranceProvider'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['authorizenet'] \ No newline at end of file diff --git a/authorizenet/models.py b/authorizenet/models.py index c1cfe0b..5f92979 100644 --- a/authorizenet/models.py +++ b/authorizenet/models.py @@ -1,4 +1,13 @@ from django.db import models +from django.forms.models import model_to_dict + +from .conf import settings +from .cim import add_profile, get_profile, update_payment_profile, \ + create_payment_profile, delete_profile, delete_payment_profile + +from .managers import CustomerProfileManager +from .exceptions import BillingError + RESPONSE_CHOICES = ( ('1', 'Approved'), @@ -194,3 +203,155 @@ class CIMResponse(models.Model): @property def success(self): return self.result == 'Ok' + + def raise_if_error(self): + if not self.success: + raise BillingError(self.result_text) + + +class CustomerProfile(models.Model): + + """Authorize.NET customer profile""" + + customer = models.OneToOneField(settings.CUSTOMER_MODEL, + related_name='customer_profile') + profile_id = models.CharField(max_length=50) + + def save(self, *args, **kwargs): + data = kwargs.pop('data', {}) + sync = kwargs.pop('sync', True) + if not self.id and sync: + self.push_to_server(data) + super(CustomerProfile, self).save(*args, **kwargs) + + def delete(self): + """Delete the customer profile remotely and locally""" + response = delete_profile(self.profile_id) + response.raise_if_error() + super(CustomerProfile, self).delete() + + def push_to_server(self, data): + output = add_profile(self.customer.pk, data, data) + output['response'].raise_if_error() + self.profile_id = output['profile_id'] + self.payment_profile_ids = output['payment_profile_ids'] + + def sync(self): + """Overwrite local customer profile data with remote data""" + output = get_profile(self.profile_id) + output['response'].raise_if_error() + for payment_profile in output['payment_profiles']: + instance, created = CustomerPaymentProfile.objects.get_or_create( + customer_profile=self, + payment_profile_id=payment_profile['payment_profile_id'] + ) + instance.sync(payment_profile) + + objects = CustomerProfileManager() + + def __unicode__(self): + return self.profile_id + + +class CustomerPaymentProfile(models.Model): + + """Authorize.NET customer payment profile""" + + customer = models.ForeignKey(settings.CUSTOMER_MODEL, + related_name='payment_profiles') + customer_profile = models.ForeignKey('CustomerProfile', + related_name='payment_profiles') + payment_profile_id = models.CharField(max_length=50) + first_name = models.CharField(max_length=50, blank=True) + last_name = models.CharField(max_length=50, blank=True) + company = models.CharField(max_length=50, blank=True) + phone_number = models.CharField(max_length=25, blank=True) + fax_number = models.CharField(max_length=25, blank=True) + address = models.CharField(max_length=60, blank=True) + city = models.CharField(max_length=40, blank=True) + state = models.CharField(max_length=40, blank=True) + zip = models.CharField(max_length=20, blank=True, verbose_name="ZIP") + country = models.CharField(max_length=60, blank=True) + card_number = models.CharField(max_length=16, blank=True) + expiration_date = models.DateField(blank=True, null=True) + card_code = None + + def __init__(self, *args, **kwargs): + self.card_code = kwargs.pop('card_code', None) + return super(CustomerPaymentProfile, self).__init__(*args, **kwargs) + + def save(self, *args, **kwargs): + if kwargs.pop('sync', True): + self.push_to_server() + self.card_code = None + self.card_number = "XXXX%s" % self.card_number[-4:] + super(CustomerPaymentProfile, self).save(*args, **kwargs) + + def push_to_server(self): + if not self.customer_profile_id: + try: + self.customer_profile = CustomerProfile.objects.get( + customer=self.customer) + except CustomerProfile.DoesNotExist: + pass + if self.payment_profile_id: + response = update_payment_profile( + self.customer_profile.profile_id, + self.payment_profile_id, + self.raw_data, + self.raw_data, + ) + elif self.customer_profile_id: + output = create_payment_profile( + self.customer_profile.profile_id, + self.raw_data, + self.raw_data, + ) + response = output['response'] + self.payment_profile_id = output['payment_profile_id'] + else: + output = add_profile( + self.customer.id, + self.raw_data, + self.raw_data, + ) + response = output['response'] + self.customer_profile = CustomerProfile.objects.create( + customer=self.customer, + profile_id=output['profile_id'], + sync=False, + ) + self.payment_profile_id = output['payment_profile_ids'][0] + response.raise_if_error() + + @property + def raw_data(self): + """Return data suitable for use in payment and billing forms""" + data = model_to_dict(self) + data['card_code'] = getattr(self, 'card_code') + return data + + def sync(self, data): + """Overwrite local customer payment profile data with remote data""" + for k, v in data.get('billing', {}).items(): + setattr(self, k, v) + self.card_number = data.get('credit_card', {}).get('card_number', + self.card_number) + self.save(sync=False) + + def delete(self): + """Delete the customer payment profile remotely and locally""" + response = delete_payment_profile(self.customer_profile.profile_id, + self.payment_profile_id) + response.raise_if_error() + return super(CustomerPaymentProfile, self).delete() + + def update(self, **data): + """Update the customer payment profile remotely and locally""" + for key, value in data.items(): + setattr(self, key, value) + self.save() + return self + + def __unicode__(self): + return self.payment_profile_id diff --git a/authorizenet/tests/tests.py b/authorizenet/tests/tests.py deleted file mode 100644 index 1706576..0000000 --- a/authorizenet/tests/tests.py +++ /dev/null @@ -1,149 +0,0 @@ -from datetime import datetime -from django.test import TestCase -from xml.dom.minidom import parseString -from httmock import urlmatch, HTTMock - -from authorizenet.cim import extract_form_data, extract_payment_form_data, \ - add_profile - - -cim_url_match = urlmatch(scheme='https', netloc=r'^api\.authorize\.net$', - path=r'^/xml/v1/request\.api$') - - -success_response = ( - '' - '<{0}>' - '' - 'Ok' - 'I00001Successful.' - '' - '6666' - '' - '7777' - '' - '' - '' - '' -) - - -def xml_to_dict(node): - """Recursively convert minidom XML node to dictionary""" - node_data = {} - if node.nodeType == node.TEXT_NODE: - node_data = node.data - elif node.nodeType not in (node.DOCUMENT_NODE, node.DOCUMENT_TYPE_NODE): - node_data.update(node.attributes.items()) - if node.nodeType not in (node.TEXT_NODE, node.DOCUMENT_TYPE_NODE): - for child in node.childNodes: - child_name, child_data = xml_to_dict(child) - if not child_data: - child_data = '' - if child_name not in node_data: - node_data[child_name] = child_data - else: - if not isinstance(node_data[child_name], list): - node_data[child_name] = [node_data[child_name]] - node_data[child_name].append(child_data) - if node_data.keys() == ['#text']: - node_data = node_data['#text'] - if node.nodeType == node.DOCUMENT_NODE: - return node_data - else: - return node.nodeName, node_data - - -class ExtractFormDataTests(TestCase): - - """Tests for utility functions converting form data to CIM data""" - - def test_extract_form_data(self): - new_data = extract_form_data({'word': "1", 'multi_word_str': "2"}) - self.assertEqual(new_data, {'word': "1", 'multiWordStr': "2"}) - - def test_extract_payment_form_data(self): - data = extract_payment_form_data({ - 'card_number': "1111", - 'expiration_date': datetime(2020, 5, 1), - 'card_code': "123", - }) - self.assertEqual(data, { - 'cardNumber': "1111", - 'expirationDate': "2020-05", - 'cardCode': "123", - }) - - -class AddProfileTests(TestCase): - - """Tests for add_profile utility function""" - - def setUp(self): - self.payment_form_data = { - 'card_number': "1111222233334444", - 'expiration_date': datetime(2020, 5, 1), - 'card_code': "123", - } - self.billing_form_data = { - 'first_name': "Danielle", - 'last_name': "Thompson", - 'company': "", - 'address': "101 Broadway Avenue", - 'city': "San Diego", - 'state': "CA", - 'country': "US", - 'zip': "92101", - } - self.request_data = { - 'createCustomerProfileRequest': { - 'xmlns': 'AnetApi/xml/v1/schema/AnetApiSchema.xsd', - 'profile': { - 'merchantCustomerId': '42', - 'paymentProfiles': { - 'billTo': { - 'firstName': 'Danielle', - 'lastName': 'Thompson', - 'company': '', - 'address': '101 Broadway Avenue', - 'city': 'San Diego', - 'state': 'CA', - 'zip': '92101', - 'country': 'US' - }, - 'payment': { - 'creditCard': { - 'cardCode': '123', - 'cardNumber': '1111222233334444', - 'expirationDate': '2020-05' - } - } - } - }, - 'merchantAuthentication': { - 'transactionKey': 'key', - 'name': 'loginid' - }, - } - } - - def test_add_profile_minimal(self): - """Success test with minimal complexity""" - @cim_url_match - def request_handler(url, request): - request_xml = parseString(request.body) - self.assertEqual(xml_to_dict(request_xml), self.request_data) - return success_response.format('createCustomerProfileResponse') - with HTTMock(request_handler): - result = add_profile(42, self.payment_form_data, - self.billing_form_data) - response = result.pop('response') - self.assertEqual(result, { - 'profile_id': '6666', - 'payment_profile_ids': ['7777'], - 'shipping_profile_ids': [], - }) - self.assertEqual(response.result, 'Ok') - self.assertEqual(response.result_code, 'I00001') - self.assertEqual(response.result_text, 'Successful.') - self.assertIsNone(response.transaction_response) diff --git a/authorizenet/utils.py b/authorizenet/utils.py index bc475d9..6b636ad 100644 --- a/authorizenet/utils.py +++ b/authorizenet/utils.py @@ -1,21 +1,21 @@ import hmac -from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from authorizenet.conf import settings from authorizenet.helpers import AIMPaymentHelper from authorizenet.models import Response from authorizenet.signals import payment_was_successful, payment_was_flagged def get_fingerprint(x_fp_sequence, x_fp_timestamp, x_amount): - msg = '^'.join([settings.AUTHNET_LOGIN_ID, + msg = '^'.join([settings.LOGIN_ID, x_fp_sequence, x_fp_timestamp, x_amount ]) + '^' - return hmac.new(settings.AUTHNET_TRANSACTION_KEY, msg).hexdigest() + return hmac.new(settings.TRANSACTION_KEY, msg).hexdigest() def extract_form_data(form_data): @@ -23,10 +23,10 @@ def extract_form_data(form_data): form_data.items())) AIM_DEFAULT_DICT = { - 'x_login': settings.AUTHNET_LOGIN_ID, - 'x_tran_key': settings.AUTHNET_TRANSACTION_KEY, + 'x_login': settings.LOGIN_ID, + 'x_tran_key': settings.TRANSACTION_KEY, 'x_delim_data': "TRUE", - 'x_delim_char': getattr(settings, 'AUTHNET_DELIM_CHAR', "|"), + 'x_delim_char': settings.DELIM_CHAR, 'x_relay_response': "FALSE", 'x_type': "AUTH_CAPTURE", 'x_method': "CC" @@ -48,10 +48,10 @@ def process_payment(form_data, extra_data): data = extract_form_data(form_data) data.update(extract_form_data(extra_data)) data['x_exp_date'] = data['x_exp_date'].strftime('%m%y') - if getattr(settings, 'AUTHNET_FORCE_TEST_REQUEST', False): + if settings.FORCE_TEST_REQUEST: data['x_test_request'] = 'TRUE' - if hasattr(settings, 'AUTHNET_EMAIL_CUSTOMER'): - data['x_email_customer'] = settings.AUTHNET_EMAIL_CUSTOMER + if settings.EMAIL_CUSTOMER is not None: + data['x_email_customer'] = settings.EMAIL_CUSTOMER return create_response(data) @@ -74,6 +74,6 @@ def capture_transaction(response, extra_data=None): if not data.get('x_amount', None): data['x_amount'] = response.amount data['x_type'] = 'PRIOR_AUTH_CAPTURE' - if getattr(settings, 'AUTHNET_FORCE_TEST_REQUEST', False): + if settings.FORCE_TEST_REQUEST: data['x_test_request'] = 'TRUE' return create_response(data) diff --git a/authorizenet/views.py b/authorizenet/views.py index f030578..19d1d4a 100644 --- a/authorizenet/views.py +++ b/authorizenet/views.py @@ -3,11 +3,13 @@ except ImportError: import md5 as hashlib -from django.conf import settings +from authorizenet.conf import settings from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt +from django.views.generic.edit import CreateView, UpdateView -from authorizenet.forms import AIMPaymentForm, BillingAddressForm +from authorizenet.forms import AIMPaymentForm, BillingAddressForm, CustomerPaymentForm +from authorizenet.models import CustomerProfile, CustomerPaymentProfile from authorizenet.models import Response from authorizenet.signals import payment_was_successful, payment_was_flagged from authorizenet.utils import process_payment, combine_form_data @@ -16,14 +18,14 @@ @csrf_exempt def sim_payment(request): response = Response.objects.create_from_dict(request.POST) - MD5_HASH = getattr(settings, "AUTHNET_MD5_HASH", "") + MD5_HASH = settings.MD5_HASH hash_is_valid = True #if MD5-Hash value is provided, use it to validate response if MD5_HASH: hash_is_valid = False hash_value = hashlib.md5(''.join([MD5_HASH, - settings.AUTHNET_LOGIN_ID, + settings.LOGIN_ID, response.trans_id, response.amount])).hexdigest() @@ -121,3 +123,23 @@ def validate_payment_form(self): self.payment_template, self.context ) + + +class PaymentProfileCreateView(CreateView): + template_name = 'authorizenet/create_payment_profile.html' + form_class = CustomerPaymentForm + + def get_form_kwargs(self): + kwargs = super(PaymentProfileCreateView, self).get_form_kwargs() + kwargs['customer'] = self.request.user + return kwargs + + +class PaymentProfileUpdateView(UpdateView): + template_name = 'authorizenet/update_payment_profile.html' + form_class = CustomerPaymentForm + + def get_form_kwargs(self): + kwargs = super(PaymentProfileUpdateView, self).get_form_kwargs() + kwargs['customer'] = self.request.user + return kwargs diff --git a/runtests.py b/runtests.py index d02ca06..89cb44f 100644 --- a/runtests.py +++ b/runtests.py @@ -11,9 +11,14 @@ AUTHNET_LOGIN_ID="loginid", AUTHNET_TRANSACTION_KEY="key", INSTALLED_APPS=( - 'authorizenet.tests', + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'tests', 'authorizenet', ), + ROOT_URLCONF='tests.urls', + STATIC_URL='/static/', DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}}, ) diff --git a/authorizenet/tests/__init__.py b/tests/__init__.py similarity index 100% rename from authorizenet/tests/__init__.py rename to tests/__init__.py diff --git a/authorizenet/tests/models.py b/tests/models.py similarity index 100% rename from authorizenet/tests/models.py rename to tests/models.py diff --git a/tests/templates/authorizenet/create_payment_profile.html b/tests/templates/authorizenet/create_payment_profile.html new file mode 100644 index 0000000..c592814 --- /dev/null +++ b/tests/templates/authorizenet/create_payment_profile.html @@ -0,0 +1,4 @@ +
+ {{ form }} + +
diff --git a/tests/templates/authorizenet/update_payment_profile.html b/tests/templates/authorizenet/update_payment_profile.html new file mode 100644 index 0000000..c592814 --- /dev/null +++ b/tests/templates/authorizenet/update_payment_profile.html @@ -0,0 +1,4 @@ +
+ {{ form }} + +
diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py new file mode 100644 index 0000000..75dda46 --- /dev/null +++ b/tests/tests/__init__.py @@ -0,0 +1,3 @@ +from .cim import * +from .views import * +from .models import * diff --git a/tests/tests/cim.py b/tests/tests/cim.py new file mode 100644 index 0000000..dfbb27f --- /dev/null +++ b/tests/tests/cim.py @@ -0,0 +1,80 @@ +from copy import deepcopy +from datetime import datetime +from django.test import TestCase +from xml.dom.minidom import parseString +from httmock import HTTMock + +from authorizenet.cim import extract_form_data, extract_payment_form_data, \ + add_profile + +from .utils import xml_to_dict +from .mocks import cim_url_match, customer_profile_success +from .test_data import create_profile_success + + +class ExtractFormDataTests(TestCase): + + """Tests for utility functions converting form data to CIM data""" + + def test_extract_form_data(self): + new_data = extract_form_data({'word': "1", 'multi_word_str': "2"}) + self.assertEqual(new_data, {'word': "1", 'multiWordStr': "2"}) + + def test_extract_payment_form_data(self): + data = extract_payment_form_data({ + 'card_number': "1111", + 'expiration_date': datetime(2020, 5, 1), + 'card_code': "123", + }) + self.assertEqual(data, { + 'cardNumber': "1111", + 'expirationDate': "2020-05", + 'cardCode': "123", + }) + + +class AddProfileTests(TestCase): + + """Tests for add_profile utility function""" + + def setUp(self): + self.payment_form_data = { + 'card_number': "5586086832001747", + 'expiration_date': datetime(2020, 5, 1), + 'card_code': "123", + } + self.billing_form_data = { + 'first_name': "Danielle", + 'last_name': "Thompson", + 'company': "", + 'address': "101 Broadway Avenue", + 'city': "San Diego", + 'state': "CA", + 'country': "US", + 'zip': "92101", + } + self.request_data = deepcopy(create_profile_success) + profile = self.request_data['createCustomerProfileRequest']['profile'] + del profile['paymentProfiles']['billTo']['phoneNumber'] + del profile['paymentProfiles']['billTo']['faxNumber'] + + def test_add_profile_minimal(self): + """Success test with minimal complexity""" + @cim_url_match + def request_handler(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), self.request_data) + return customer_profile_success.format('createCustomerProfileResponse') + with HTTMock(request_handler): + result = add_profile(42, self.payment_form_data, + self.billing_form_data) + response = result.pop('response') + self.assertEqual(result, { + 'profile_id': '6666', + 'payment_profile_ids': ['7777'], + 'shipping_profile_ids': [], + }) + self.assertEqual(response.result, 'Ok') + self.assertEqual(response.result_code, 'I00001') + self.assertEqual(response.result_text, 'Successful.') + self.assertIsNone(response.transaction_response) diff --git a/tests/tests/mocks.py b/tests/tests/mocks.py new file mode 100644 index 0000000..db518a6 --- /dev/null +++ b/tests/tests/mocks.py @@ -0,0 +1,46 @@ +from httmock import urlmatch + + +cim_url_match = urlmatch(scheme='https', netloc=r'^api\.authorize\.net$', + path=r'^/xml/v1/request\.api$') + + +delete_success = ( + '' + '<{0}>' + '' + 'Ok' + 'I00001Successful.' + '' + '' +) + + +customer_profile_success = ( + '' + '<{0}>' + '' + 'Ok' + 'I00001Successful.' + '' + '6666' + '' + '7777' + '' + '' + '' + '' +) + + +payment_profile_success = ( + '' + '<{0}>' + '' + 'Ok' + 'I00001Successful.' + '' + '6666' + '7777' + '' +) diff --git a/tests/tests/models.py b/tests/tests/models.py new file mode 100644 index 0000000..32aa548 --- /dev/null +++ b/tests/tests/models.py @@ -0,0 +1,77 @@ +from httmock import HTTMock, with_httmock +from xml.dom.minidom import parseString +from django.test import TestCase +from authorizenet.models import CustomerProfile + +from .utils import create_user, xml_to_dict +from .mocks import cim_url_match, customer_profile_success, delete_success +from .test_data import create_empty_profile_success, delete_profile_success + + +class RequestError(Exception): + pass + + +def error_on_request(url, request): + raise RequestError("CIM Request") + + +class CustomerProfileModelTests(TestCase): + + """Tests for CustomerProfile model""" + + def setUp(self): + self.user = create_user(id=42, username='billy', password='password') + + def create_profile(self): + return CustomerProfile.objects.create( + customer=self.user, profile_id='6666', sync=False) + + def test_create_sync_no_data(self): + @cim_url_match + def request_handler(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), + create_empty_profile_success) + return customer_profile_success.format( + 'createCustomerProfileResponse') + profile = CustomerProfile(customer=self.user) + with HTTMock(error_on_request): + self.assertRaises(RequestError, profile.save) + self.assertEqual(profile.profile_id, '') + with HTTMock(request_handler): + profile.save(sync=True) + self.assertEqual(profile.profile_id, '6666') + + @with_httmock(error_on_request) + def test_create_no_sync(self): + profile = CustomerProfile(customer=self.user) + profile.save(sync=False) + self.assertEqual(profile.profile_id, '') + + @with_httmock(error_on_request) + def test_edit(self): + profile = self.create_profile() + self.assertEqual(profile.profile_id, '6666') + profile.profile_id = '7777' + profile.save() + self.assertEqual(profile.profile_id, '7777') + profile.profile_id = '8888' + profile.save(sync=True) + self.assertEqual(profile.profile_id, '8888') + profile.profile_id = '9999' + profile.save(sync=False) + self.assertEqual(profile.profile_id, '9999') + + def test_delete(self): + @cim_url_match + def request_handler(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), + delete_profile_success) + return delete_success.format( + 'deleteCustomerProfileResponse') + profile = self.create_profile() + with HTTMock(request_handler): + profile.delete() + self.assertEqual(profile.__class__.objects.count(), 0) diff --git a/tests/tests/test_data.py b/tests/tests/test_data.py new file mode 100644 index 0000000..bec7753 --- /dev/null +++ b/tests/tests/test_data.py @@ -0,0 +1,126 @@ +create_empty_profile_success = { + 'createCustomerProfileRequest': { + 'xmlns': 'AnetApi/xml/v1/schema/AnetApiSchema.xsd', + 'profile': { + 'merchantCustomerId': '42', + }, + 'merchantAuthentication': { + 'transactionKey': 'key', + 'name': 'loginid', + }, + } +} + + +create_profile_success = { + 'createCustomerProfileRequest': { + 'xmlns': 'AnetApi/xml/v1/schema/AnetApiSchema.xsd', + 'profile': { + 'merchantCustomerId': '42', + 'paymentProfiles': { + 'billTo': { + 'firstName': 'Danielle', + 'lastName': 'Thompson', + 'company': '', + 'phoneNumber': '', + 'faxNumber': '', + 'address': '101 Broadway Avenue', + 'city': 'San Diego', + 'state': 'CA', + 'zip': '92101', + 'country': 'US' + }, + 'payment': { + 'creditCard': { + 'cardCode': '123', + 'cardNumber': "5586086832001747", + 'expirationDate': '2020-05' + } + } + } + }, + 'merchantAuthentication': { + 'transactionKey': 'key', + 'name': 'loginid', + }, + } +} + + +update_profile_success = { + 'updateCustomerPaymentProfileRequest': { + 'xmlns': 'AnetApi/xml/v1/schema/AnetApiSchema.xsd', + 'customerProfileId': '6666', + 'paymentProfile': { + 'customerPaymentProfileId': '7777', + 'billTo': { + 'firstName': 'Danielle', + 'lastName': 'Thompson', + 'company': '', + 'phoneNumber': '', + 'faxNumber': '', + 'address': '101 Broadway Avenue', + 'city': 'San Diego', + 'state': 'CA', + 'zip': '92101', + 'country': 'US' + }, + 'payment': { + 'creditCard': { + 'cardCode': '123', + 'cardNumber': "5586086832001747", + 'expirationDate': '2020-05' + } + } + }, + 'merchantAuthentication': { + 'transactionKey': 'key', + 'name': 'loginid', + }, + } +} + + +create_payment_profile_success = { + 'createCustomerPaymentProfileRequest': { + 'xmlns': 'AnetApi/xml/v1/schema/AnetApiSchema.xsd', + 'customerProfileId': '6666', + 'paymentProfile': { + 'billTo': { + 'firstName': 'Danielle', + 'lastName': 'Thompson', + 'phoneNumber': '', + 'faxNumber': '', + 'company': '', + 'address': '101 Broadway Avenue', + 'city': 'San Diego', + 'state': 'CA', + 'zip': '92101', + 'country': 'US' + }, + 'payment': { + 'creditCard': { + 'cardCode': '123', + 'cardNumber': "5586086832001747", + 'expirationDate': '2020-05' + } + } + }, + 'merchantAuthentication': { + 'transactionKey': 'key', + 'name': 'loginid', + }, + } +} + + +delete_profile_success = { + 'deleteCustomerProfileRequest': { + 'xmlns': u'AnetApi/xml/v1/schema/AnetApiSchema.xsd', + 'customerProfileId': '6666', + 'merchantAuthentication': { + 'transactionKey': 'key', + 'name': 'loginid' + }, + }, +} diff --git a/tests/tests/utils.py b/tests/tests/utils.py new file mode 100644 index 0000000..e4079eb --- /dev/null +++ b/tests/tests/utils.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import User + + +def create_user(id=None, username='', password=''): + user = User(username=username) + user.id = id + user.set_password(password) + user.save() + return user + + +def xml_to_dict(node): + """Recursively convert minidom XML node to dictionary""" + node_data = {} + if node.nodeType == node.TEXT_NODE: + node_data = node.data + elif node.nodeType not in (node.DOCUMENT_NODE, node.DOCUMENT_TYPE_NODE): + node_data.update(node.attributes.items()) + if node.nodeType not in (node.TEXT_NODE, node.DOCUMENT_TYPE_NODE): + for child in node.childNodes: + child_name, child_data = xml_to_dict(child) + if not child_data: + child_data = '' + if child_name not in node_data: + node_data[child_name] = child_data + else: + if not isinstance(node_data[child_name], list): + node_data[child_name] = [node_data[child_name]] + node_data[child_name].append(child_data) + if node_data.keys() == ['#text']: + node_data = node_data['#text'] + if node.nodeType == node.DOCUMENT_NODE: + return node_data + else: + return node.nodeName, node_data diff --git a/tests/tests/views.py b/tests/tests/views.py new file mode 100644 index 0000000..a3ebdfe --- /dev/null +++ b/tests/tests/views.py @@ -0,0 +1,148 @@ +from datetime import date +from django.test import LiveServerTestCase +from xml.dom.minidom import parseString +from httmock import HTTMock + +from authorizenet.models import CustomerProfile, CustomerPaymentProfile + +from .utils import create_user, xml_to_dict +from .mocks import cim_url_match, customer_profile_success, \ + payment_profile_success +from .test_data import create_profile_success, update_profile_success, \ + create_payment_profile_success + + +class PaymentProfileCreationTests(LiveServerTestCase): + + def setUp(self): + self.user = create_user(id=42, username='billy', password='password') + self.client.login(username='billy', password='password') + + def test_create_new_customer_get(self): + response = self.client.get('/customers/create') + self.assertNotIn("This field is required", response.content) + self.assertIn("Credit Card Number", response.content) + self.assertIn("City", response.content) + + def test_create_new_customer_post_error(self): + response = self.client.post('/customers/create') + self.assertIn("This field is required", response.content) + self.assertIn("Credit Card Number", response.content) + self.assertIn("City", response.content) + + def test_create_new_customer_post_success(self): + @cim_url_match + def create_customer_success(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), create_profile_success) + return customer_profile_success.format('createCustomerProfileResponse') + with HTTMock(create_customer_success): + response = self.client.post('/customers/create', { + 'card_number': "5586086832001747", + 'expiration_date_0': "5", + 'expiration_date_1': "2020", + 'card_code': "123", + 'first_name': "Danielle", + 'last_name': "Thompson", + 'address': "101 Broadway Avenue", + 'city': "San Diego", + 'state': "CA", + 'country': "US", + 'zip': "92101", + }, follow=True) + self.assertIn("success", response.content) + + def test_create_new_payment_profile_post_success(self): + @cim_url_match + def request_handler(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), + create_payment_profile_success) + return payment_profile_success.format('createCustomerPaymentProfileResponse') + CustomerProfile.objects.create(customer=self.user, profile_id='6666', sync=False) + with HTTMock(request_handler): + response = self.client.post('/customers/create', { + 'card_number': "5586086832001747", + 'expiration_date_0': "5", + 'expiration_date_1': "2020", + 'card_code': "123", + 'first_name': "Danielle", + 'last_name': "Thompson", + 'address': "101 Broadway Avenue", + 'city': "San Diego", + 'state': "CA", + 'country': "US", + 'zip': "92101", + }, follow=True) + self.assertIn("success", response.content) + + +class PaymentProfileUpdateTests(LiveServerTestCase): + + def setUp(self): + self.user = create_user(id=42, username='billy', password='password') + profile = CustomerProfile(customer=self.user, profile_id='6666') + profile.save(sync=False) + self.payment_profile = CustomerPaymentProfile( + customer=self.user, + customer_profile=profile, + payment_profile_id='7777', + ) + self.payment_profile.save(sync=False) + self.client.login(username='billy', password='password') + + def test_update_profile_get(self): + response = self.client.get('/customers/update') + self.assertNotIn("This field is required", response.content) + self.assertIn("Credit Card Number", response.content) + self.assertIn("City", response.content) + + def test_update_profile_post_error(self): + response = self.client.post('/customers/update') + self.assertIn("This field is required", response.content) + self.assertIn("Credit Card Number", response.content) + self.assertIn("City", response.content) + + def test_update_profile_post_success(self): + @cim_url_match + def create_customer_success(url, request): + request_xml = parseString(request.body) + self.assertEqual(xml_to_dict(request_xml), + update_profile_success) + return customer_profile_success.format('updateCustomerProfileResponse') + with HTTMock(create_customer_success): + response = self.client.post('/customers/update', { + 'card_number': "5586086832001747", + 'expiration_date_0': "5", + 'expiration_date_1': "2020", + 'card_code': "123", + 'first_name': "Danielle", + 'last_name': "Thompson", + 'address': "101 Broadway Avenue", + 'city': "San Diego", + 'state': "CA", + 'country': "US", + 'zip': "92101", + }, follow=True) + self.assertIn("success", response.content) + payment_profile = self.user.customer_profile.payment_profiles.get() + self.assertEqual(payment_profile.raw_data, { + 'id': payment_profile.id, + 'customer_profile': self.user.customer_profile.id, + 'customer': self.user.id, + 'payment_profile_id': '7777', + 'card_number': 'XXXX1747', + 'expiration_date': date(2020, 5, 31), + 'card_code': None, + 'first_name': 'Danielle', + 'last_name': 'Thompson', + 'company': '', + 'fax_number': '', + 'phone_number': '', + 'address': '101 Broadway Avenue', + 'city': 'San Diego', + 'state': 'CA', + 'country': 'US', + 'zip': '92101', + }) + diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..8db5d51 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url, patterns +from .views import CreateCustomerView, UpdateCustomerView, success_view + +urlpatterns = patterns( + '', + url(r"^customers/create$", CreateCustomerView.as_view()), + url(r"^customers/update$", UpdateCustomerView.as_view()), + url(r"^success$", success_view), +) diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 0000000..64b861d --- /dev/null +++ b/tests/views.py @@ -0,0 +1,20 @@ +from django.http import HttpResponse +from authorizenet.views import PaymentProfileCreateView, PaymentProfileUpdateView + + +class CreateCustomerView(PaymentProfileCreateView): + def get_success_url(self): + return '/success' + + +class UpdateCustomerView(PaymentProfileUpdateView): + + def get_object(self): + return self.request.user.customer_profile.payment_profiles.get() + + def get_success_url(self): + return '/success' + + +def success_view(request): + return HttpResponse("success")