Created
August 28, 2010 18:36
-
-
Save sr3d/555437 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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