Skip to content

Instantly share code, notes, and snippets.

@sr3d
Created August 28, 2010 18:36
Show Gist options
  • Save sr3d/555437 to your computer and use it in GitHub Desktop.
Save sr3d/555437 to your computer and use it in GitHub Desktop.
# lib/paypal.rb
require 'net/http'
module Paypal
# paypal_cert: the content of the paypal certificate
# business_key: content of the private key
# business_cert: content of the public cert
# business_certid: the ID of the public cert, provided by PayPal on the web
# params: a hash of all the parameters.
def encrypt_parameters paypal_cert, business_key, business_cert, business_certid, params = {}
require 'openssl'
# Convert the key and certificates into OpenSSL-friendly objects.
paypal_cert = OpenSSL::X509::Certificate.new(paypal_cert)
business_key = OpenSSL::PKey::RSA.new(business_key)
business_cert = OpenSSL::X509::Certificate.new(business_cert)
# Put the certificate ID back into the parameter hash the way Paypal wants it.
params[:cert_id] = business_certid
# Prepare a string of data for encryption
data = ""
params.each_pair {|k,v| data << "#{k}=#{v}\n"}
# Sign the data with our key/certificate pair
signed = OpenSSL::PKCS7::sign(business_cert, business_key, data, [], OpenSSL::PKCS7::BINARY)
# Encrypt the signed data with Paypal's public certificate.
encrypted = OpenSSL::PKCS7::encrypt([paypal_cert], signed.to_der, OpenSSL::Cipher::Cipher::new("DES3"), OpenSSL::PKCS7::BINARY)
encrypted
end
module_function :encrypt_parameters
def verify_ipn paypal_url, params
end
# Parser and handler for incoming Instant payment notifications from paypal.
# The Example shows a typical handler in a rails application. Note that this
# is an example, please read the Paypal API documentation for all the details
# on creating a safe payment controller.
#
# Example
#
# class BackendController < ApplicationController
#
# def paypal_ipn
# notify = Paypal::Notification.new(request.raw_post)
#
# order = Order.find(notify.item_id)
#
# if notify.acknowledge
# begin
#
# if notify.complete? and order.total == notify.amount and notify.business == '[email protected]'
# order.status = 'success'
#
# shop.ship(order)
# else
# logger.error("Failed to verify Paypal's notification, please investigate")
# end
#
# rescue => e
# order.status = 'failed'
# raise
# ensure
# order.save
# end
# end
#
# render :nothing
# end
# end
class Notification
attr_accessor :params
attr_accessor :raw
# Overwrite this url. It points to the Paypal sandbox by default.
# Please note that the Paypal technical overview (doc directory)
# speaks of a https:// address for production use. In my tests
# this https address does not in fact work.
#
# Example:
# Paypal::Notification.ipn_url = https://www.paypal.com/cgi-bin/webscr
#
cattr_accessor :ipn_url
@@ipn_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr'
# Overwrite this certificate. It contains the Paypal sandbox certificate by default.
#
# Example:
# Paypal::Notification.paypal_cert = File::read("paypal_cert.pem")
cattr_accessor :paypal_cert
@@paypal_cert = """
-----BEGIN CERTIFICATE-----
MIIDoTCCAwqgAwIBAgIBADANBgkqhkiG9w0BAQUFADCBmDELMAkGA1UEBhMCVVMx
EzARBgNVBAgTCkNhbGlmb3JuaWExETAPBgNVBAcTCFNhbiBKb3NlMRUwEwYDVQQK
EwxQYXlQYWwsIEluYy4xFjAUBgNVBAsUDXNhbmRib3hfY2VydHMxFDASBgNVBAMU
C3NhbmRib3hfYXBpMRwwGgYJKoZIhvcNAQkBFg1yZUBwYXlwYWwuY29tMB4XDTA0
MDQxOTA3MDI1NFoXDTM1MDQxOTA3MDI1NFowgZgxCzAJBgNVBAYTAlVTMRMwEQYD
VQQIEwpDYWxpZm9ybmlhMREwDwYDVQQHEwhTYW4gSm9zZTEVMBMGA1UEChMMUGF5
UGFsLCBJbmMuMRYwFAYDVQQLFA1zYW5kYm94X2NlcnRzMRQwEgYDVQQDFAtzYW5k
Ym94X2FwaTEcMBoGCSqGSIb3DQEJARYNcmVAcGF5cGFsLmNvbTCBnzANBgkqhkiG
9w0BAQEFAAOBjQAwgYkCgYEAt5bjv/0N0qN3TiBL+1+L/EjpO1jeqPaJC1fDi+cC
6t6tTbQ55Od4poT8xjSzNH5S48iHdZh0C7EqfE1MPCc2coJqCSpDqxmOrO+9QXsj
HWAnx6sb6foHHpsPm7WgQyUmDsNwTWT3OGR398ERmBzzcoL5owf3zBSpRP0NlTWo
nPMCAwEAAaOB+DCB9TAdBgNVHQ4EFgQUgy4i2asqiC1rp5Ms81Dx8nfVqdIwgcUG
A1UdIwSBvTCBuoAUgy4i2asqiC1rp5Ms81Dx8nfVqdKhgZ6kgZswgZgxCzAJBgNV
BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMREwDwYDVQQHEwhTYW4gSm9zZTEV
MBMGA1UEChMMUGF5UGFsLCBJbmMuMRYwFAYDVQQLFA1zYW5kYm94X2NlcnRzMRQw
EgYDVQQDFAtzYW5kYm94X2FwaTEcMBoGCSqGSIb3DQEJARYNcmVAcGF5cGFsLmNv
bYIBADAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4GBAFc288DYGX+GX2+W
P/dwdXwficf+rlG+0V9GBPJZYKZJQ069W/ZRkUuWFQ+Opd2yhPpneGezmw3aU222
CGrdKhOrBJRRcpoO3FjHHmXWkqgbQqDWdG7S+/l8n1QfDPp+jpULOrcnGEUY41Im
jZJTylbJQ1b5PBBjGiP0PpK48cdF
-----END CERTIFICATE-----
"""
# Creates a new paypal object. Pass the raw html you got from paypal in.
# In a rails application this looks something like this
#
# def paypal_ipn
# paypal = Paypal::Notification.new(request.raw_post)
# ...
# end
def initialize(post)
empty!
parse(post)
end
# Was the transaction complete?
def complete?
status == "Completed"
end
def failed?
status == "Failed"
end
def denied?
status == "Denied"
end
# When was this payment received by the client.
# sometimes it can happen that we get the notification much later.
# One possible scenario is that our web application was down. In this case paypal tries several
# times an hour to inform us about the notification
def received_at
Time.parse params['payment_date']
end
# Whats the status of this transaction?
def status
params['payment_status']
end
# Id of this transaction (paypal number)
def transaction_id
params['txn_id']
end
# What type of transaction are we dealing with?
# "cart" "send_money" "web_accept" are possible here.
def type
params['txn_type']
end
# the money amount we received in X.2 decimal.
def gross
params['mc_gross']
end
# the markup paypal charges for the transaction
def fee
params['mc_fee']
end
# What currency have we been dealing with
def currency
params['mc_currency']
end
# This is the item number which we submitted to paypal
def item_id
params['item_number']
end
# This is the email address associated to the paypal account that recieved
# the payment.
def business
params['business']
end
# This is the item_name which you passed to paypal
def item_name
params['item_name']
end
# This is the invocie which you passed to paypal
def invoice
params['invoice']
end
# This is the invocie which you passed to paypal
def test?
params['test_ipn'] == '1'
end
# This is the custom field which you passed to paypal
def custom
params['custom']
end
# Reason for pending status, nil if status is not pending.
def pending_reason
params['pending_reason']
end
# Reason for reversed status, nil if status is not reversed.
def reason_code
params['reason_code']
end
# Memo entered by customer if any
def memo
params['memo']
end
# Well, the payment type.
def payment_type
params['payment_type']
end
# The exchange rate used if there was a conversion.
def exchange_rate
params['exchange_rate']
end
def gross_cents
(gross.to_f * 100.0).round
end
# This combines the gross and currency and returns a proper Money object.
# this requires the money library located at http://dist.leetsoft.com/api/money
def amount
return Money.new(gross_cents, currency) rescue ArgumentError
return Money.new(gross_cents) # maybe you have an own money object which doesn't take a currency?
end
# reset the notification.
def empty!
@params = Hash.new
@raw = ""
end
# Acknowledge the transaction to paypal. This method has to be called after a new
# ipn arrives. Paypal will verify that all the information we received are correct and will return a
# ok or a fail.
#
# Example:
#
# def paypal_ipn
# notify = PaypalNotification.new(request.raw_post)
#
# if notify.acknowledge
# ... process order ... if notify.complete?
# else
# ... log possible hacking attempt ...
# end
def acknowledge
payload = raw
uri = URI.parse(self.class.ipn_url)
request_path = "#{uri.path}?cmd=_notify-validate"
request = Net::HTTP::Post.new(request_path)
request['Content-Length'] = "#{payload.size}"
request['User-Agent'] = "paypal-ruby -- http://rubyforge.org/projects/paypal/"
http = Net::HTTP.new(uri.host, uri.port)
http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_strict
http.use_ssl = true
request = http.request(request, payload)
raise StandardError.new("Faulty paypal result: #{request.body}") unless ["VERIFIED", "INVALID"].include?(request.body)
request.body == "VERIFIED"
end
private
# Take the posted data and move the relevant data into a hash
def parse(post)
@raw = post
for line in post.split('&')
key, value = *line.scan( %r{^(\w+)\=(.*)$} ).flatten
params[key] = CGI.unescape(value)
end
end
end
end
class Subscription < ActiveRecord::Base
belongs_to :account
belongs_to :plan
def self.create_temporary_subscription(account)
subscription = Subscription.new
subscription.account_id = account.id
subscription.trial_till = account.created_at.to_date + $SITE_CONFIG['trial_period'].days
subscription.is_active = true
subscription.amount = 0
subscription.plan_id = -1
subscription.last_payment_at = nil # user never pays before
subscription.next_payment_at = subscription.trial_till.to_date # when expiration ends, in UTC
subscription.save!
subscription
end
# ipn: PaymentNotification object
def self.process_ipn ipn
# https://www.paypalobjects.com/en_US/ebook/subscriptions/Appx-ipn_subscription_variables.html
if (Rails.env == 'production' && ipn.test?) || (!ipn.test? && Rails.env != 'production')
msg = "Invalid IPN #{Rails.env} but IPN is in mode test = #{ipn.test?}"
logger.error ipn
raise msg
end
# only process valid subscription
# load the account, subscription, and plan data
subscription_info = parse_ipn_custom(ipn.custom)
# account = Account.find ipn.params['invoice']
subscription = find subscription_info['subscription_id']
# subscr_failed
# subscr_cancel
# subscr_payment
# subscr_signup
# subscr_eot
# subscr_modify
# Make sure we have the subscr_id and the plan_id
subscription.subscr_id ||= ipn.params['subscr_id']
subscription.plan_id = subscription_info['plan_id'] if subscription.plan_id == -1
subscription.amount = ipn.params['amount3'] if subscription.amount == 0
# Process the transaction
case ipn.params['txn_type']
when 'subscr_signup'
# we already set all the params (subscr_id, plan_id, amount) needed, so don't need
# to handle anything special here
when 'subscr_payment'
# extend the subscription's next_payment_at by the plan's subscription_period
if ipn.params['payment_status'] == 'Completed'
plan = Plan.find subscription_info['plan_id']
subscription.last_payment_at = Time.parse(ipn.params['payment_date']).utc.to_date
# edge case: when account was inactive for a while, got paid, should reset the building cycle again
if subscription.is_active
subscription.next_payment_at = subscription.next_payment_at.utc.to_date +
(plan.subscription_period).months
else
# if subscription is not active, we re-active the subscription, then extend
# based on the last_payment date
subscription.next_payment_at = subscription.last_payment_at.to_date +
(plan.subscription_period).months
end
subscription.is_active = true
subscription.inactive_at = nil
subscription.inactive_reason = nil
else
# don't need to worry about other stuff, for now.
end
subscription.payment_status = ipn.params['payment_status']
when 'subscr_failed'
# mark the subscription is no longer active
subscription.is_active = false
subscription.inactive_at = Time.now
subscription.inactive_reason = 'subscr_failed'
when 'subscr_cancel'
subscription.is_active = false
subscription.inactive_at = Time.now
subscription.inactive_reason = 'subscr_cancel'
subscription.subscr_id = nil
when 'subscr_modify'
# don't know what to deal with it right now
when 'subscr_eot'
# end of term of subscription, just let the subscription expired
# should we clear the subscription ID ?
else
# other events
# just lock up disable the subsription.
subscription.is_active = false
subscription.inactive_at = Time.now
subscription.inactive_reason = ipn.params['txn_type']
end
# finally, update the subscription
subscription.save
end
def self.parse_ipn_custom( ipn_custom )
params = {}
for line in ipn_custom.split('|')
key, value = *line.scan( %r{^(\w+)\=(.*)$} ).flatten
params[key] = CGI.unescape(value)
end
params
end
def is_active?
today = Time.now.to_date
return false if !self.is_active
return true if self.trial_till >= today
return true if self.next_payment_at && self.next_payment_at.to_date >= today
# get here meaning the subscription is not active anymore
false
end
def trial?
trial_till >= Time.now.to_date
end
end
require 'test_helper'
class SubscriptionTest < ActiveSupport::TestCase
context 'receiving ipn' do
setup do
@account = Factory(:mary_account)
@now, @today = Time.now.utc, Time.now.utc.to_date
@plan = Plan.find(3)
end
teardown do
@sub.destroy if @sub
end
should 'raise exception if production is sent test IPN' do
end
context 'new subscription' do
setup do
@sub = Subscription.create_temporary_subscription(@account)
@ipn = PaymentNotification.new Factory.attributes_for( :subscr_signup ).clone
@ipn.params['invoice'] = @account.id
@ipn.params['custom'] = [ "subscription_id=#{@sub.id}",
"account_id=#{@account.id}",
"plan_id=#{@plan.id}",
"amount=#{@plan.amount}"
].join("|")
end
teardown do
@sub.destroy if @sub
end
should 'initialize subscription according to plan' do
assert_nil @sub.subscr_id, 'subscr_id should not be set'
assert_equal -1, @sub.plan_id, 'plan_id shouldnt be set'
assert_equal 'subscr_signup', @ipn.params['txn_type']
Subscription.process_ipn @ipn
@sub.reload
assert @sub.is_active?, "subscription should be active now"
assert_equal @plan.id, @sub.plan_id, "plan_id should match"
assert_nil @sub.payment_status, 'payment_status should not be set'
assert_nil @sub.last_payment_at, "last_payment date should be nil"
assert_equal @today + $SITE_CONFIG['trial_period'].days,
@sub.next_payment_at.to_date, 'should due when trial is over'
assert_equal @ipn.params['subscr_id'], @sub.subscr_id, 'subscr_id should be saved'
end
should 'extend subscription on successful payment' do
@sub.trial_till = @now - 50.days
@sub.next_payment_at = next_payment_at = @now.to_date - 30.days
@sub.save
assert_false @sub.is_active?
ipn = @ipn.clone
ipn.params['txn_type'] = 'subscr_payment'
ipn.params['payment_status'] = 'Completed'
ipn.params['payment_date'] = @today.to_s(:long)
Subscription.process_ipn ipn
@sub.reload
plan = Plan.find @sub.plan_id
assert @sub.is_active?
assert_equal 'Completed', @sub.payment_status
assert_equal @today, @sub.last_payment_at.to_date, 'last_payment_at should be today'
assert_equal next_payment_at.to_date + plan.subscription_period.months, @sub.next_payment_at.to_date,
'subscription should be due according to plan subscription_period'
end
end
context 'expired subscription' do
setup do
@account.subscription.destroy if @account.subscription
@sub = Factory :trial_monthly_subscription,
:account_id => @account.id,
:trial_till => 50.days.ago,
:last_payment_at => 35.days.ago,
:next_payment_at => 5.day.ago,
:is_active => false
@ipn = PaymentNotification.new Factory.attributes_for( :subscr_payment_completed )
@ipn.params['invoice'] = @account.id
@ipn.params['custom'] = [ "subscription_id=#{@sub.id}",
"account_id=#{@account.id}",
"plan_id=#{@sub.plan_id}",
"amount=#{@sub.amount}"
].join("|")
end
should 'be renewed and extended subscription when there is a payment' do
assert_false @sub.is_active?
# debugger
Subscription.process_ipn @ipn
@sub.reload
plan = Plan.find @sub.plan_id
assert @sub.is_active?, 'sub should now active'
assert_equal 'Completed', @sub.payment_status
assert_equal @today, @sub.last_payment_at.to_date, 'last payment should be reset to today (in utc)'
assert_equal @today + plan.subscription_period.months, @sub.next_payment_at.to_date,
'next_payment_at should be reset to today plus the subscription period'
end
# context 'with valid ipn' do
# should 'extend account' do
#
# end
# end
end
end
end
def select_subscription_plan
@subscription = current_account.subscription
@subscription = Subscription.create_temporary_subscription(current_account) unless @subscription
@plans = Plan.all(:order => 'amount ASC')
# find the coupon from session
@coupon = session[:coupon]
# @coupon = AppCoupon.find(3)
if @coupon
@plans.each do |plan|
plan.apply_discount @coupon
end
else
# incentivize user upon new registration
@plans.each do |plan|
plan.apply_discount $SITE_CONFIG['new_signup_discount']
end
end
key_directory = Rails.root.join( 'config', 'paypal')
@paypal_cert = File::read( key_directory.join( $SITE_CONFIG["paypal_cert"] ) )
@business_key = File::read( key_directory.join( $SITE_CONFIG["paypal_business_key"] ) )
@business_cert = File::read( key_directory.join( $SITE_CONFIG["paypal_business_cert"] ) )
@business_certid = $SITE_CONFIG["paypal_certid"]
@encrypted_plan_fields = []
# now create the params
@plans.each do |plan|
subscription = {
:cmd => '_xclick-subscriptions',
:recurring => 1, # make sure this recurring!
:sra => 1,
:src => 1,
:business => $SITE_CONFIG['paypal_email'],
:notify_url => $SITE_CONFIG['site_url'] + $SITE_CONFIG['paypal_ipn_url'],
:item_name => plan.name,
:item_number => current_account.id,
# Common settings
:no_note => 1,
:no_shipping => 1,
:invoice => current_account.id,
:return => $SITE_CONFIG['site_url'] + $SITE_CONFIG['paypal_return_url'],
:cancel => $SITE_CONFIG['site_url'] + $SITE_CONFIG['paypal_cancel_url'],
# Trial
# :a1 => 0, # free
# :p1 => $SITE_CONFIG['trial_period'], # for 14
# :t1 => 'D', # day
# Subscription
:a3 => '%.2f' % plan.adjusted_amount,
:p3 => plan.subscription_period,
:t3 => 'M',
# Custom variables
:custom => [ "subscription_id=#{@subscription.id}",
"account_id=#{current_account.id}",
"plan_id=#{plan.id}",
"amount=#{plan.amount}"
].join('|')
}
@encrypted_plan_fields[ plan.id ] = Paypal::encrypt_parameters @paypal_cert,
@business_key, @business_cert, @business_certid,
subscription
# logger.debug subscription
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment