[Checkins] SVN: zc.authorizedotnet/trunk/src/ fill in project

Benji York benji at zope.com
Mon May 22 10:19:07 EDT 2006


Log message for revision 68239:
  fill in project
  

Changed:
  A   zc.authorizedotnet/trunk/src/
  A   zc.authorizedotnet/trunk/src/zc/
  A   zc.authorizedotnet/trunk/src/zc/authorizedotnet/
  A   zc.authorizedotnet/trunk/src/zc/authorizedotnet/README.txt
  A   zc.authorizedotnet/trunk/src/zc/authorizedotnet/__init__.py
  A   zc.authorizedotnet/trunk/src/zc/authorizedotnet/processing.py
  A   zc.authorizedotnet/trunk/src/zc/authorizedotnet/tests.py

-=-
Added: zc.authorizedotnet/trunk/src/zc/authorizedotnet/README.txt
===================================================================
--- zc.authorizedotnet/trunk/src/zc/authorizedotnet/README.txt	2006-05-22 14:04:56 UTC (rev 68238)
+++ zc.authorizedotnet/trunk/src/zc/authorizedotnet/README.txt	2006-05-22 14:19:03 UTC (rev 68239)
@@ -0,0 +1,238 @@
+Authorize.Net Integration
+=========================
+
+Authorize.Net provides credit card (henceforth "CC") processing via a protocol
+on top of HTTPS.  Authorize.Net's customers are "merchants".  The merchant is
+the entity accepting a CC as payment.  This package provides a simple
+interface to Authorize.Net's "Advanced Integration Method" (AIM).
+
+Several terms used in this document:
+
+    - authorize: check validity of CC information and for sufficient balance
+    - capture: the approval of transfer of funds from the CC holder to the
+      merchant
+    - settlement: the actual transfer of funds from the CC holder
+      to the merchant
+    - credit: issuing a refund from the merchant to the card holder
+    - voiding: canceling a previous transaction
+
+Settlement is performed in daily batches.  The cut-off time for which is
+specified in the merchant's settings avialable on the Authorize.Net merchant
+interface.
+
+There are many other settings which can be configured via the merchant
+interface, but this module attempts to work independently of most of them.
+Where specific settings are required they will be marked with the phrase
+"required merchant interface setting".
+
+Warning
+-------
+
+This package has NOT been tested in production.  It is made available as a
+foundation for future work.  We consider it "alpha" quality.
+
+
+Transaction Keys
+----------------
+
+Each AIM transaction must be accomponied by a merchant login and a
+"transaction key".  This key is obtained from the merchant interface.  After
+importing the CcProcessor class you must pass it your login and transaction
+key:
+
+    >>> from zc.authorizedotnet.processing import CcProcessor
+    >>> cc = CcProcessor(server=SERVER_NAME, login=LOGIN, key=KEY)
+
+
+Authorizing
+-----------
+
+To authorize a charge use the ``authorize`` method.  It returns a
+``Transaction`` object.
+
+    >>> result = cc.authorize(amount='1.00', card_num='4007000000027',
+    ...                       exp_date='0530')
+
+The result object contains details about the transaction.
+
+    >>> result.response
+    'approved'
+    >>> result.response_reason
+    'This transaction has been approved.'
+    >>> result.approval_code
+    '123456'
+    >>> result.trans_id
+    '123456789'
+
+The server succesfully processed the transaction:
+
+    >>> transactions = server.getTransactions()
+    >>> transactions[result.trans_id]
+    'Authorized/Pending Capture'
+
+
+Capturing Authorized Transactions
+---------------------------------
+
+Now if we want to capture the transaction that was previously authorized, we
+can do so.
+
+    >>> result = cc.captureAuthorized(trans_id=result.trans_id)
+    >>> result.response
+    'approved'
+
+The server shows that the transaction has been captured and is awaiting
+setttlement:
+
+    >>> transactions = server.getTransactions()
+    >>> transactions[result.trans_id]
+    'Captured/Pending Settlement'
+
+
+Voiding Transactions
+--------------------
+
+If we need to stop a transaction that has not yet been completed (like the
+capturing of the authorized transaction above) we can do so with the ``void``
+method.
+
+    >>> result = cc.void(trans_id=result.trans_id)
+    >>> result.response
+    'approved'
+
+The server shows that the transaction was voided:
+
+    >>> transactions = server.getTransactions()
+    >>> transactions[result.trans_id]
+    'Voided'
+
+
+Transaction Errors
+------------------
+
+If something about the transaction is erronious, the transaction results
+indicate so.
+
+    >>> result = cc.authorize(amount='2.00', card_num='4007000000027',
+    ...                       exp_date='0599')
+
+The result object reflecs the error.
+
+    >>> result.response
+    'error'
+    >>> result.response_reason
+    'The credit card has expired.'
+
+The valid values for the ``response`` attribute are 'approved', 'declined',
+and 'error'.
+
+
+Address Verification System (AVS)
+---------------------------------
+
+AVS is used to assert that the billing information provided for a transaction
+must match (to some degree or another) the cardholder's actual billing data.
+The gateway can be configured to disallow transactions that don't meet certain
+AVS critieria.
+
+
+    >>> result = cc.authorize(amount='27.00', card_num='4222222222222',
+    ...                       exp_date='0530', address='000 Bad Street',
+    ...                       zip='90210')
+    >>> result.response
+    'declined'
+    >>> result.response_reason
+    'The transaction resulted in an AVS mismatch...'
+
+
+Duplicate Window
+----------------
+
+The gateway provides a way to detect and reject duplicate transactions within
+a certain time window.  Any transaction with the same CC information (card
+number and exproation date) and amount duplicated within the window will be
+rejected.
+
+The first transaction will work.
+
+    >>> result = cc.authorize(amount='3.00', card_num='4007000000027',
+    ...                       exp_date='0530')
+    >>> result.response
+    'approved'
+
+A duplicate transaction will fail with an appropriate message.
+
+    >>> result2 = cc.authorize(amount='3.00', card_num='4007000000027',
+    ...                       exp_date='0530')
+    >>> result2.response
+    'error'
+    >>> result2.response_reason
+    'A duplicate transaction has been submitted.'
+
+The default window size is 120 seconds, but any other value (including 0) can
+be provided by passing ``duplicate_window`` to the transaction method.
+
+    >>> cc.captureAuthorized(trans_id=result.trans_id).response
+    'approved'
+
+    >>> cc.captureAuthorized(trans_id=result.trans_id).response_reason
+    'This transaction has already been captured.'
+
+    >>> cc.captureAuthorized(trans_id=result.trans_id, duplicate_window=0
+    ...                     ).response
+    'approved'
+
+But voiding doesn't report errors if the same transaction is voided inside
+the duplicate window.
+
+    >>> cc.void(trans_id=result.trans_id).response
+    'approved'
+
+    >>> cc.void(trans_id=result.trans_id).response
+    'approved'
+
+The MD5 Hash Security Feature
+-----------------------------
+
+Authorize.Net provides for validating transaction responses via an MD5 hash.
+The required merchant interface setting to use this feature is under
+"Settings and Profile" and then "MD5 Hash".  Enter a "salt" value in the
+fields provided and submit the form.  You may then provide the ``salt``
+parameter to the CcProcessor constructor to enable response validation.
+
+WARNING: The format of the "amount" field is very important for this feature
+to work correctly.  The field must be formatted in the "cononical" way for the
+currency in use.  For the US dollar that means no leading zeros and two (and
+only two) decimal places.  If the amount is not formatted properly in the
+request the hashes will not match and the transaction will raise an exception.
+
+If you want to enable hash checking, provide a ``salt`` value to the
+``CcProcessor`` constructor.  If an incorrect salt value is used, or the
+hash given in the transaction doesn't match the true hash value an exception
+is raised.
+
+    >>> cc = CcProcessor(server='test.authorize.net', login='cnpdev3137',
+    ...                  key='BLRGz8xUKRafHh1A', salt='wrong')
+    >>> result = cc.authorize(amount='10.00', card_num='4007000000027',
+    ...                       exp_date='0530')
+    Traceback (most recent call last):
+        ...
+    ValueError: MD5 hash is not valid (trans_id = ...)
+
+
+Error Checking
+--------------
+
+If you don't pass a string for the amount when doing an authorization, an
+exception will be raised.  This is to avoid charging the wrong amount due to
+floating point representation issues.
+
+    >>> cc.authorize(amount=5.00, number='4007000000027', expiration='0530')
+    Traceback (most recent call last):
+        ...
+    ValueError: amount must be a string
+
+TODO
+----
+
+ - explain and demonstrate duplicate transaction window


Property changes on: zc.authorizedotnet/trunk/src/zc/authorizedotnet/README.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.authorizedotnet/trunk/src/zc/authorizedotnet/__init__.py
===================================================================
--- zc.authorizedotnet/trunk/src/zc/authorizedotnet/__init__.py	2006-05-22 14:04:56 UTC (rev 68238)
+++ zc.authorizedotnet/trunk/src/zc/authorizedotnet/__init__.py	2006-05-22 14:19:03 UTC (rev 68239)
@@ -0,0 +1 @@
+#


Property changes on: zc.authorizedotnet/trunk/src/zc/authorizedotnet/__init__.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.authorizedotnet/trunk/src/zc/authorizedotnet/processing.py
===================================================================
--- zc.authorizedotnet/trunk/src/zc/authorizedotnet/processing.py	2006-05-22 14:04:56 UTC (rev 68238)
+++ zc.authorizedotnet/trunk/src/zc/authorizedotnet/processing.py	2006-05-22 14:19:03 UTC (rev 68239)
@@ -0,0 +1,103 @@
+##############################################################################
+#
+# Copyright (c) 2004 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+
+from M2Crypto import httpslib
+import urllib
+import md5
+
+class TransactionResult(object):
+    def __init__(self, fields):
+        response_code_map = {'1': 'approved', '2': 'declined', '3': 'error'}
+        self.response = response_code_map[fields[0]]
+        self.response_reason = fields[3]
+        TESTING_PREFIX = '(TESTMODE) '
+        if self.response_reason.startswith(TESTING_PREFIX):
+            self.response_reason = self.response_reason[len(TESTING_PREFIX):]
+        self.approval_code = fields[4]
+        self.trans_id = fields[6]
+        self.amount = fields[9]
+        self.hash = fields[37]
+
+    def validateHash(self, login, salt):
+        value = ''.join([salt, login, self.trans_id, self.amount])
+        return self.hash == md5.new(value).hexdigest()
+
+
+class AuthorizeNetConnection(object):
+    def __init__(self, server, login, key, salt=None):
+        self.server = server
+        self.login = login
+        self.salt = salt
+        self.delimiter = '|'
+        self.standard_fields = dict(
+            x_login = login,
+            x_tran_key = key,
+            x_version = '3.1',
+            x_delim_data = 'TRUE',
+            x_delim_char = self.delimiter,
+            x_relay_response = 'FALSE',
+            x_method = 'CC',
+            )
+
+    def sendTransaction(self, **kws):
+        # if the card number passed in is the "generate an error" card...
+        if kws.get('card_num') == '4222222222222':
+            # ... turn on test mode (that's the only time that card works)
+            kws['test_request'] = 'TRUE'
+
+        fields = dict(('x_'+key, value) for key, value in kws.iteritems())
+        fields.update(self.standard_fields)
+        body = urllib.urlencode(fields)
+
+        if self.server.startswith('localhost:'):
+            server, port = self.server.split(':')
+            conn = httpslib.HTTPConnection(server, port)
+        else:
+            conn = httpslib.HTTPSConnection(self.server)
+        conn.putrequest('POST', '/gateway/transact.dll')
+        conn.putheader('content-type', 'application/x-www-form-urlencoded')
+        conn.putheader('content-length', len(body))
+        conn.endheaders()
+        conn.send(body)
+
+        response = conn.getresponse()
+        fields = response.read().split(self.delimiter)
+        result = TransactionResult(fields)
+
+        if (self.salt is not None
+        and not result.validateHash(self.login, self.salt)):
+            raise ValueError('MD5 hash is not valid (trans_id = %r)'
+                             % result.trans_id)
+
+        return result
+
+
+class CcProcessor(object):
+    def __init__(self, server, login, key, salt=None):
+        self.connection = AuthorizeNetConnection(server, login, key, salt)
+
+    def authorize(self, **kws):
+        if not isinstance(kws['amount'], basestring):
+            raise ValueError('amount must be a string')
+
+        type = 'AUTH_ONLY'
+        return self.connection.sendTransaction(type=type, **kws)
+
+    def captureAuthorized(self, **kws):
+        type = 'PRIOR_AUTH_CAPTURE'
+        return self.connection.sendTransaction(type=type, **kws)
+
+    def void(self, **kws):
+        type = 'VOID'
+        return self.connection.sendTransaction(type=type, **kws)


Property changes on: zc.authorizedotnet/trunk/src/zc/authorizedotnet/processing.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native

Added: zc.authorizedotnet/trunk/src/zc/authorizedotnet/tests.py
===================================================================
--- zc.authorizedotnet/trunk/src/zc/authorizedotnet/tests.py	2006-05-22 14:04:56 UTC (rev 68238)
+++ zc.authorizedotnet/trunk/src/zc/authorizedotnet/tests.py	2006-05-22 14:19:03 UTC (rev 68239)
@@ -0,0 +1,261 @@
+##############################################################################
+#
+# Copyright (c) 2004 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+
+from BeautifulSoup import BeautifulSoup
+from zope.testbrowser.browser import Browser
+from zope.testing import doctest, renormalizing
+import BaseHTTPServer
+import SimpleHTTPServer
+import cgi
+import httplib
+import os
+import re
+import threading
+import unittest
+
+TEST_SERVER_PORT = 30423
+
+class ScrapedMerchantUiServer(object):
+    initialized = False
+
+    def __init__(self, server, login, password):
+        login_page = 'https://%s/ui/themes/anet/merch.app' % server
+        self.browser = Browser()
+        self.browser.open(login_page)
+        self.browser.getControl(name='MerchantLogin').value = login
+        self.browser.getControl(name='Password').value = password
+        self.browser.getControl('Log In').click()
+
+    def getTransactions(self):
+        self.browser.getLink('Unsettled Transactions').click()
+        soup = BeautifulSoup(self.browser.contents)
+        transactions = {}
+        for row in soup('tr', ['TblRowFont', 'TblRowFontWithBGColor']):
+            cells = row('td')
+            if len(cells) == 8:
+                txn_id = cells[0].contents[0].contents[0]
+                txn_status = cells[2].contents[0]
+                transactions[txn_id] = txn_status
+
+        return transactions
+
+    def close(self):
+        self.browser.mech_browser.close()
+
+
+class InProcessServer(object):
+    info = {}
+    last_transaction_id = 0
+    seen_auths = []
+    seen_captures = []
+
+    def getTransactions(self):
+        return self.info
+
+
+class TestRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+
+    def do_POST(self):
+        fields = cgi.FieldStorage(fp=self.rfile, headers=self.headers,
+                                  environ = {'REQUEST_METHOD':'POST'},
+                                  keep_blank_values = 1)
+
+        if fields.getvalue('x_delim_data') != 'TRUE':
+            raise ValueError('the test server only knows how to do'
+                             ' delimited output')
+
+        if fields.getvalue('x_relay_response') != 'FALSE':
+            raise ValueError('AIM requires x_relay_response be "FALSE"')
+
+        required_fields = ('x_login x_tran_key x_version x_delim_char '
+                           'x_method').split()
+        for f in required_fields:
+            if f not in fields:
+                raise ValueError('the %r field is required' % f)
+
+        delimiter = fields['x_delim_char'].value
+
+        self.send_response(200)
+        self.send_header('Content-Type', 'text/plain')
+        self.end_headers()
+
+        out = getattr(self, fields['x_type'].value)(fields)
+        self.wfile.write(delimiter.join(out))
+
+    @staticmethod
+    def makeOut(response_code, reason_text, approval_code, trans_id, amount):
+        hash = 'DEADBEEF'
+        out = [''] * 38
+        out[:9] = [response_code, '', '', reason_text, approval_code, '',
+                trans_id, '', '', amount]
+        out[37] = hash
+        return out
+
+    def getTransactionId(self):
+        in_process_server.last_transaction_id += 1
+        return '%09d' % in_process_server.last_transaction_id
+
+    def AUTH_ONLY(self, fields):
+        amount = fields.getvalue('x_amount')
+        card_num = fields.getvalue('x_card_num')
+        expr = fields.getvalue('x_exp_date')
+
+        response_code = '1'
+        reason_text = 'This transaction has been approved.'
+        approval_code = '123456'
+        trans_id = self.getTransactionId()
+
+        if expr[2:4] > '80':
+            response_code = '3'
+            reason_text = 'The credit card has expired.'
+
+        if card_num == '4222222222222' and amount.split('.')[0] == '27':
+            response_code = '2'
+            reason_text = ('The transaction resulted in an AVS mismatch. The'
+                           ' address provided does not match billing address'
+                           ' of cardholder.')
+
+        if (amount, card_num) in in_process_server.seen_auths:
+            response_code = '3'
+            reason_text = 'A duplicate transaction has been submitted.'
+
+        out = self.makeOut(response_code, reason_text, approval_code,
+                           trans_id, amount)
+
+        if (amount, card_num) not in in_process_server.seen_auths:
+            in_process_server.seen_auths.append( (amount, card_num) )
+            in_process_server.info[trans_id] = 'Authorized/Pending Capture'
+
+        return out
+
+    def VOID(self, fields):
+        trans_id = fields.getvalue('x_trans_id')
+
+        response_code = '1'
+        reason_text = 'This transaction has been approved.'
+        approval_code = '123456'
+        out = self.makeOut(response_code, reason_text, approval_code,
+                           trans_id, amount='')
+        in_process_server.info[trans_id] = 'Voided'
+        return out
+
+    prev_captures = []
+    def PRIOR_AUTH_CAPTURE(self, fields):
+        trans_id = fields.getvalue('x_trans_id')
+        duplicate_window = fields.getvalue('x_duplicate_window')
+
+        response_code = '1'
+        reason_text = 'This transaction has been approved.'
+        approval_code = '123456'
+
+        if (trans_id in in_process_server.seen_captures
+        and duplicate_window != '0'):
+            response_code = '3'
+            reason_text = 'This transaction has already been captured.'
+
+        out = self.makeOut(response_code, reason_text, approval_code,
+                           trans_id, amount='')
+
+        if trans_id not in in_process_server.seen_captures:
+            in_process_server.seen_captures.append(trans_id)
+            in_process_server.info[trans_id] = 'Captured/Pending Settlement'
+
+        return out
+
+    def do_QUIT(self):
+        self.send_response(200)
+        self.end_headers()
+        self.server.stop = True
+
+    def log_message(self, format, *args):
+        pass
+
+
+class TestHttpServer(BaseHTTPServer.HTTPServer):
+
+    def serve_forever (self):
+        """Handle one request at a time until stopped."""
+        self.stop = False
+        while not self.stop:
+            self.handle_request()
+
+
+http_server = TestHttpServer(('localhost', TEST_SERVER_PORT),
+                             TestRequestHandler)
+in_process_server = InProcessServer()
+
+def localSetUp(test):
+    http_server.thread = threading.Thread(target=http_server.serve_forever)
+    http_server.thread.start()
+    test.globs['LOGIN'] = 'LOGIN'
+    test.globs['KEY'] = 'KEY'
+
+def localTearDown(test):
+    conn = httplib.HTTPConnection('localhost:%d' % TEST_SERVER_PORT)
+    conn.request('QUIT', '/')
+    conn.getresponse()
+    http_server.thread.join()
+
+def remoteSetUp(test):
+    login = os.environ.get('AUTHORIZE_DOT_NET_LOGIN')
+    password = os.environ.get('AUTHORIZE_DOT_NET_PASSWORD')
+    key = os.environ.get('AUTHORIZE_DOT_NET_TRANSACTION_KEY')
+
+    if login is None or password is None or key is None:
+        raise RuntimeError('all of AUTHORIZE_DOT_NET_LOGIN,'
+                           ' AUTHORIZE_DOT_NET_PASSWORD, and'
+                           ' AUTHORIZE_DOT_NET_TRANSACTION_KEY must be'
+                           ' provided in order to run the zc.authorizedotnet'
+                           ' tests against the Authorize.Net test server.')
+
+    test.globs['server'] = ScrapedMerchantUiServer('test.authorize.net', login,
+                                                   password)
+    test.globs['LOGIN'] = login
+    test.globs['KEY'] = key
+
+def remoteTearDown(test):
+    test.globs['server'].close()
+
+def test_suite():
+    checker = renormalizing.RENormalizing([
+        (re.compile(r"'\d{6}'"), "'123456'"), # for approval codes
+        (re.compile(r"'\d{9}'"), "'123456789'"), # for transaction IDs
+        ])
+
+    remote = doctest.DocFileSuite(
+            'README.txt',
+            globs = dict(
+                SERVER_NAME='test.authorize.net',
+                ),
+            optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS,
+            checker = checker,
+            setUp = remoteSetUp,
+            tearDown = remoteTearDown,
+            )
+    remote.level = 5
+    local = doctest.DocFileSuite(
+            'README.txt',
+            globs = dict(
+                server=in_process_server,
+                SERVER_NAME='localhost:%s' % TEST_SERVER_PORT,
+                ),
+            optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS,
+            checker = checker,
+            setUp = localSetUp,
+            tearDown = localTearDown,
+            )
+    return unittest.TestSuite((remote, local))
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')


Property changes on: zc.authorizedotnet/trunk/src/zc/authorizedotnet/tests.py
___________________________________________________________________
Name: svn:keywords
   + Id
Name: svn:eol-style
   + native



More information about the Checkins mailing list