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..0518b5b 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 []
+
+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..107f1f1 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 'expirationDate' in payment_data:
+ 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..0185a3b
--- /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 = {'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..26ecafe
--- /dev/null
+++ b/authorizenet/exceptions.py
@@ -0,0 +1,3 @@
+class BillingError(Exception):
+
+ """Error due to Authorize.NET request"""
diff --git a/authorizenet/fields.py b/authorizenet/fields.py
index 2fd4a63..e58de0d 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
diff --git a/authorizenet/forms.py b/authorizenet/forms.py
index 648bb82..86d18a8 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,47 @@ 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.expiration_date = self.cleaned_data['expiration_date']
+ 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..fae1d75
--- /dev/null
+++ b/authorizenet/managers.py
@@ -0,0 +1,45 @@
+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
+
+
+class CustomerPaymentProfileManager(models.Manager):
+
+ def create(self, **kwargs):
+ """Create new Authorize.NET customer payment profile"""
+ sync = kwargs.pop('sync', True)
+ obj = self.model(**kwargs)
+ self._for_write = True
+ obj.save(force_insert=True, using=self.db, sync=sync)
+ return obj
diff --git a/authorizenet/models.py b/authorizenet/models.py
index c1cfe0b..3dd5736 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, CustomerPaymentProfileManager
+from .exceptions import BillingError
+
RESPONSE_CHOICES = (
('1', 'Approved'),
@@ -194,3 +203,160 @@ class CIMResponse(models.Model):
@property
def success(self):
return self.result == 'Ok'
+
+
+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', {})
+ if not self.id and kwargs.pop('sync', True):
+ 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)
+ if not response.success:
+ raise BillingError("Error deleting customer profile")
+ super(CustomerProfile, self).delete()
+
+ def push_to_server(self, data):
+ output = add_profile(self.customer.pk, data, data)
+ if not output['response'].success:
+ raise BillingError("Error creating customer profile")
+ 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"""
+ response, payment_profiles = get_profile(self.profile_id)
+ if not response.success:
+ raise BillingError("Error syncing remote customer profile")
+ for payment_profile in 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 = None
+ card_code = None
+
+ def __init__(self, *args, **kwargs):
+ self.card_code = kwargs.pop('card_code', None)
+ self.expiration_date = kwargs.pop('expiration_date', None)
+ return super(CustomerPaymentProfile, self).__init__(*args, **kwargs)
+
+ def save(self, *args, **kwargs):
+ if kwargs.pop('sync', True):
+ self.push_to_server()
+ self.expiration_date = None
+ 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]
+ if not response.success:
+ raise BillingError()
+
+ @property
+ def raw_data(self):
+ """Return data suitable for use in payment and billing forms"""
+ data = model_to_dict(self)
+ data.update(dict((k, getattr(self, k))
+ for k in ('expiration_date', '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()
+
+ def delete(self):
+ """Delete the customer payment profile remotely and locally"""
+ response = delete_payment_profile(self.customer_profile.profile_id,
+ self.payment_profile_id)
+ if not response.success:
+ raise BillingError("Error deleting customer payment profile")
+ 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
+
+ objects = CustomerPaymentProfileManager()
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}>'
- 'I00001
I00001
I00001