If you benefit from web2py hope you feel encouraged to pay it forward by contributing back to society in whatever form you choose!

To implement my integration with PayPal i started with putting together code that generates an encrypted form post to paypal for all of my cart actions. If you do this, and you configure paypal to only accept signed requests the user cannot tamper with your form and change the price of an item.

to do this i installed the M2Crypto module on my system, and created a module that will do the signing of the paypal forms. Note that this does not work on the Google App Engine because M2Crypto does not run on the app engine. I have yet to find a replacement for it that runs on the App Engine, so i won't be using Paypal website payments standard in that environment.

my encryption module (crypt.py):

from M2Crypto import BIO, SMIME, X509, EVP

def paypal_encrypt(attributes, sitesettings):
    """
    Takes a list of attributes for working with paypal (in our case adding to
    the shopping cart), and encrypts them for secure transmission of item 
    details and prices.


    @type  attributes: dictionary
    @param attributes: a dictionary of the paypal request attributes.  an
      example attribute set is:

      >>> attributes = {"cert_id":sitesettings.paypal_cert_id,
                        "cmd":"_cart",
                        "business":sitesettings.cart_business,
                        "add":"1",
                        "custom":auth.user.id,
                        "item_name":"song 1 test",
                        "item_number":"song-1",
                        "amount":"0.99",
                        "currency_code":"USD",
                        "shopping_url":'http://'+\
                           Storage(globals()).request.env.http_host+\
                           URL(r=request, args=request.args),
                        "return":'http://'+\
                           Storage(globals()).request.env.http_host+\
                           URL(r=request, c='account', f='downloads'),
                        }

    @type  sitesettings: SQLStorage
    @param sitesettings: The settings stored in the database.  this method 
      requires I{tenthrow_private_key}, I{tenthrow_public_cert}, and 
      I{paypal_public_cert} to function
    @rtype: string
    @return: encrupted attribute string
    """

    plaintext = ''

    for key, value in attributes.items():
        plaintext += u'%s=%s\n' % (key, value)

    plaintext = plaintext.encode('utf-8')

    # Instantiate an SMIME object.
    s = SMIME.SMIME()

    # Load signer's key and cert. Sign the buffer.    
    s.pkey = EVP.load_key_string(sitesettings.tenthrow_private_key)
    s.x509 = X509.load_cert_string(sitesettings.tenthrow_public_cert)

    #s.load_key_bio(BIO.openfile(settings.MY_KEYPAIR), 
    #               BIO.openfile(settings.MY_CERT))

    p7 = s.sign(BIO.MemoryBuffer(plaintext), flags=SMIME.PKCS7_BINARY)

    # Load target cert to encrypt the signed message to.    
    #x509 = X509.load_cert_bio(BIO.openfile(settings.PAYPAL_CERT))    
    x509 = X509.load_cert_string(sitesettings.paypal_public_cert)

    sk = X509.X509_Stack()    
    sk.push(x509)    
    s.set_x509_stack(sk)

    # Set cipher: 3-key triple-DES in CBC mode.    
    s.set_cipher(SMIME.Cipher('des_ede3_cbc'))

    # Create a temporary buffer.    
    tmp = BIO.MemoryBuffer()

    # Write the signed message into the temporary buffer.    
    p7.write_der(tmp)

    # Encrypt the temporary buffer.    
    p7 = s.encrypt(tmp, flags=SMIME.PKCS7_BINARY)

    # Output p7 in mail-friendly format.    
    out = BIO.MemoryBuffer()    
    p7.write(out)

    return out.read()

Then in my view i construct forms and encrypt them:

{{from applications.tenthrow.modules.crypt import * }}

{{     attributes = {"cert_id":sitesettings.paypal_cert_id,
                    "cmd":"_cart",
                    "business":sitesettings.cart_business,
                    "add":"1",
                    "custom":auth.user.id,
                    "item_name":artist_name + ": " + song['name'],
                    "item_number":"song-"+str(song['cue_point_id']),
                    "amount":song['cost'],
                    "currency_code":"USD",
                    "shopping_url":full_url('http',r=request,args=request.args),
                    "return":full_url('https', r=request, c='account', \
                       f='alldownloads'),
                    }
                  encattrs = paypal_encrypt(attributes, sitesettings)
}}
<form target="_self"
        action="{{=sitesettings.cart_url}}" method="post"
        name="song{{=song['cue_point_id']}}">
  <!-- Identify your business so that you can collect the payments. -->
  <input type="hidden" name="cmd" value="_s-xclick" class="unform"/>
  <input type="hidden" name="encrypted" value="{{=encattrs}}" class="unform"/>
  <a onclick="document.song{{=song['cue_point_id']}}.submit()" class="trBtn">
     <img src="{{=URL(r=request, c='static',f='images/trIconDL.png')}}" 
           alt="Download {{=(song['name'])}}" class="original"/>
     <img src="{{=URL(r=request, c='static',f='images/trIconDL_Hover.png')}}" 
           alt="Download {{=(song['name'])}}" class="hover"/>
  </a>
  <img alt="" border="0" width="1" height="1"
        src="https://www.paypal.com/en_US/i/scr/pixel.gif" class="unform"/>
</form>

Note that the above code calls a method full_url() which is defined as:

def full_url(scheme="http",
    a=None,
    c=None,
    f=None,
    r=None,
    args=[],
    vars={},
    anchor='',
    path = None
    ):
    """
    Create a fully qualified URL.  The URL will use the same host as the
    request was made from, but will use the specified scheme.  Calls
    C{gluon.html.URL()} to construct the relative path to the host.

    if <scheme>_port is set in the settings table, append the port to the
    domain of the created URL

    @param scheme: scheme to use for the fully-qualified URL.
       (default to 'http')
    @param a: application (default to current if r is given)
    @param c: controller (default to current if r is given)
    @param f: function (default to current if r is given)
    @param r: request
    @param args: any arguments (optional)
    @param vars: any variables (optional)
    @param anchor: anchorname, without # (optional)
    @param path: the relative path to use.  if used overrides a,c,f,args, and
      vars (optional)
    """
    port = ''
    if sitesettings.has_key(scheme+"_port") and sitesettings[scheme+"_port"]:
        port = ":" + sitesettings[scheme+"_port"]
    if scheme == 'https' and sitesettings.has_key("https_scheme"):
        scheme = sitesettings.https_scheme
    url = scheme +'://' + \
        r.env.http_host.split(':')[0] + port
    if path:
        url = url + path
    else:
        url = url+URL(a=a, c=c, f=f, r=r, args=args, vars=vars, anchor=anchor)
    return url

Then I need to be able to process my IPN responses from paypal. The code below does just that. You'll see that i only process purchase requests. I also left in the code that is specific to my database about how i code product ID's and then use that product ID to create records in my database. Based on the existance of those purchase records in my database I allow the user to download the files that they purchased. So the user cannot download their purchase until the IPN message is processed. This is usually 5-30 seconds after they submitted the order. most of the time the messages are received and processed before paypal redirects the user back to my site.

my paypal.py controller (note that i have openanything in my modules directory. Visit http://diveintopython.org/ for the latest version.):

from applications.app.modules.openanything import *

def ipn():
    """
    This controller processes Instant Payment Notifications from PayPal.

    It will verify messages, and process completed cart transaction messages
    only.  all other messages are ignored for now.

    For each item purchased in the cart, the song_purchases table will be
    updated with the purchased item information, allowing the user to
    download the item.

    logs are written to /tmp/ipnresp.txt

    the PayPal IPN documentation is available at:
    https://cms.paypal.com/cms_content/US/en_US/files/developer/IPNGuide.pdf
    """
    """
    sample paypal IPN call:

    last_name=Smith&
    txn_id=597202352&
    receiver_email=seller%40paypalsandbox.com&
    payment_status=Completed&tax=2.02&
    mc_gross1=12.34&
    payer_status=verified&
    residence_country=US&
    invoice=abc1234&
    item_name1=something&
    txn_type=cart&
    item_number1=201&
    quantity1=1&
    payment_date=16%3A52%3A59+Jul.+20%2C+2009+PDT&
    first_name=John&
    mc_shipping=3.02&
    charset=windows-1252&
    custom=3&
    notify_version=2.4&
    test_ipn=1&
    receiver_id=TESTSELLERID1&
    business=seller%40paypalsandbox.com&
    mc_handling1=1.67&
    payer_id=TESTBUYERID01&
    verify_sign=AFcWxV21C7fd0v3bYYYRCpSSRl31AtrKNnsnrW3-8M8R-P38QFsqBaQM&
    mc_handling=2.06&
    mc_fee=0.44&
    mc_currency=USD&
    payer_email=buyer%40paypalsandbox.com&
    payment_type=instant&
    mc_gross=15.34&
    mc_shipping1=1.02
    """

    #@todo: come up with better logging mechanism
    logfile = "/tmp/ipnresp.txt"

    verifyurl = "https://www.paypal.com/cgi-bin/webscr"
    if request.vars.test_ipn != None and request.vars.test_ipn == '1':
        verifyurl = "https://www.sandbox.paypal.com/cgi-bin/webscr"

    params = dict(request.vars)
    params['cmd'] = '_notify-validate'

    resp = fetch(verifyurl, post_data=params)

    #the message was not verified, fail
    if resp['data'] != "VERIFIED":
        #@todo: figure out how to fail
        f = open(logfile, "a")
        f.write("Message not verified:\n")
        f.write(repr(params) + "\n\n")
        f.close()
        return None

    #check transaction type
    #@todo: deal with types that are not cart checkout
    if request.vars.txn_type != "cart":
        #for now ignore non-cart transaction messages
        f = open(logfile, "a")
        f.write("Not a cart message:\n")
        f.write(repr(params) + "\n\n")
        f.close()
        return None

    #@TODO: check that payment_status == Completed
    if request.vars.payment_status != 'Completed':
        #ignore pending transactions
        f = open(logfile, "a")
        f.write("Ignore pending transaction:\n")
        f.write(repr(params) + "\n\n")
        f.close()
        return None

    #check id not recorded
    if len(db(db.song_purchases.transaction_id==request.vars.txn_id).select())>0:
        #transaction already recorded
        f = open(logfile, "a")
        f.write("Ignoring recorded transaction:\n")
        f.write(repr(params) + "\n\n")
        f.close()
        return None

    #record transaction
    num_items = 1
    if request.vars.num_cart_items != None:
        num_items = request.vars.num_cart_items

    for i in range(1, int(num_items)+1):
        #i coded my item_number to be a tag and an ID.  the ID is
        # a key to a table in my database.
        tag, id = request.vars['item_number'+str(i)].split("-")
        if tag == "song":
            db.song_purchases.insert(auth_user=request.vars.custom,
                           cue_point=id,
                           transaction_id=request.vars.txn_id,
                           date=request.vars.payment_date.replace('.', ''))
        elif tag == "song_media":
            db.song_purchases.insert(auth_user=request.vars.custom,
                           song_media=id,
                           transaction_id=request.vars.txn_id,
                           date=request.vars.payment_date.replace('.', ''))
        elif tag == "concert":
            db.concert_purchases.insert(auth_user=request.vars.custom,
                              playlist=id,
                              transaction_id=request.vars.txn_id,
                              date=request.vars.payment_date.replace('.', ''))
        else:
            #@TODO: this is an error, what should we do here?
            f = open(logfile, "a")
            f.write("Ignoring bad item number: " + \
                        request.vars['item_number'+str(i)] + "\n")
            f.write(repr(params) + "\n\n")
            f.close()

    f = open(logfile, "a")
    f.write("Processed message:\n")
    f.write(repr(params) + "\n\n")
    f.close()
    return None

That's all folks!

Related slices

Comments (0)


Hosting graciously provided by:
Python Anywhere