Refactored and improved support for 0.40.3 and later

This commit is contained in:
Brian Quinn 2011-01-24 19:56:04 +00:00
parent b763f1ad12
commit 9086741f52
22 changed files with 545 additions and 718 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
*.swp
spec/test_app

34
Gemfile Normal file
View File

@ -0,0 +1,34 @@
source 'http://rubygems.org'
gem 'sqlite3-ruby', :require => 'sqlite3'
group :test do
gem 'rspec-rails', '= 2.4.1'
gem 'factory_girl', '= 1.3.3'
gem 'factory_girl_rails', '= 1.0.1'
gem 'rcov'
gem 'shoulda'
gem 'faker'
if RUBY_VERSION < "1.9"
gem "ruby-debug"
else
gem "ruby-debug19"
end
end
group :cucumber do
gem 'cucumber-rails'
gem 'database_cleaner', '~> 0.5.2'
gem 'nokogiri'
gem 'capybara'
gem 'factory_girl', '= 1.3.3'
gem 'factory_girl_rails', '= 1.0.1'
gem 'faker'
gem 'launchy'
if RUBY_VERSION < "1.9"
gem "ruby-debug"
else
gem "ruby-debug19"
end
end

View File

@ -11,6 +11,7 @@ This extension allows the store to use PayPal Express from two locations:
sent to the PayPal review page (along with detailed order information).
THIS FEATURE IS NOT YET COMPLETE
2. Cart Checkout - Presents the PayPal checkout button on the users Cart page and redirects the user to complete all shipping / addressing
information on PaypPal's site. This also supports PayPal's Instant Update feature to retrieve shipping options live from Spree when the user
selects / changes their shipping address on PayPal's site.
@ -24,16 +25,6 @@ USAGE (Checkout Payment)
1. Setup your application
cp config/database.yml.example config/database.yml
rake db:bootstrap
Go ahead and load sample data
Fire it up to see that it works
Shut it down
2. Configure PPE
You'll need to have a Paypal developer account (developer.paypal.com) and both buyer and seller test accounts.
@ -124,7 +115,7 @@ USAGE (Checkout Payment)
NOTES
=====
To automatically capture funds, add this to you site extension's activate method:
To automatically capture funds or enable accepting eCheck payments, add this to you site extension's activate method:
if Spree::Config.instance
Spree::Config.set(:auto_capture => true)

View File

@ -1,31 +1,69 @@
require File.expand_path('../../config/application', __FILE__)
# encoding: utf-8
require 'rubygems'
require 'rake'
require 'rake/testtask'
require 'rake/packagetask'
require 'rake/gempackagetask'
spec = eval(File.read('spree_paypal_express.gemspec'))
Rake::GemPackageTask.new(spec) do |p|
p.gem_spec = spec
end
desc "Release to gemcutter"
task :release => :package do
require 'rake/gemcutter'
Rake::Gemcutter::Tasks.new(spec).define
Rake::Task['gem:push'].invoke
end
desc "Default Task"
task :default => [ :spec ]
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new
gemfile = File.expand_path('../spec/test_app/Gemfile', __FILE__)
if File.exists?(gemfile) && %w(rcov spec cucumber).include?(ARGV.first.to_s)
require 'bundler'
ENV['BUNDLE_GEMFILE'] = gemfile
Bundler.setup
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new
require 'cucumber/rake/task'
Cucumber::Rake::Task.new do |t|
t.cucumber_opts = %w{--format pretty}
end
desc "Run specs with RCov"
RSpec::Core::RakeTask.new(:rcov) do |t|
t.rcov = true
t.rcov_opts = %w{ --exclude gems\/,spec\/,features\/}
t.verbose = true
end
end
desc "Regenerates a rails 3 app for testing"
task :test_app do
SPREE_PATH = ENV['SPREE_PATH']
raise "SPREE_PATH should be specified" unless SPREE_PATH
require File.join(SPREE_PATH, 'lib/generators/spree/test_app_generator')
class AuthTestAppGenerator < Spree::Generators::TestAppGenerator
def tweak_gemfile
append_file 'Gemfile' do
<<-gems
gem 'spree_core', :path => '#{File.join(SPREE_PATH, 'core')}'
gem 'spree_auth', :path => '#{File.join(SPREE_PATH, 'auth')}'
gem 'spree_paypal_express', :path => '#{File.dirname(__FILE__)}'
gems
end
end
def install_gems
inside "test_app" do
run 'rake spree_core:install'
run 'rake spree_auth:install'
run 'rake spree_paypal_express:install'
end
end
def migrate_db
run_migrations
end
end
AuthTestAppGenerator.start
end
namespace :test_app do
desc 'Rebuild test and cucumber databases'
task :rebuild_dbs do
system("cd spec/test_app && rake db:drop db:migrate RAILS_ENV=test && rake db:drop db:migrate RAILS_ENV=cucumber")
end
end
# require 'cucumber/rake/task'
# Cucumber::Rake::Task.new do |t|
# t.cucumber_opts = %w{--format pretty}
# end

View File

@ -1,31 +1,25 @@
# aim to unpick this later
module Spree::PaypalExpress
include ERB::Util
include ActiveMerchant::RequiresParameters
def self.included(target)
target.before_filter :redirect_to_paypal_express_form_if_needed, :only => [:update]
end
CheckoutController.class_eval do
before_filter :redirect_to_paypal_express_form_if_needed, :only => [:update]
def paypal_checkout
load_order
opts = all_opts(@order, params[:payment_method_id], 'checkout')
opts.merge!(address_options(@order))
gateway = paypal_gateway
@gateway = paypal_gateway
if Spree::Config[:auto_capture]
response = gateway.setup_purchase(opts[:money], opts)
@ppx_response = @gateway.setup_purchase(opts[:money], opts)
else
response = gateway.setup_authorization(opts[:money], opts)
@ppx_response = @gateway.setup_authorization(opts[:money], opts)
end
unless response.success?
gateway_error(response)
unless @ppx_response.success?
gateway_error(@ppx_response)
redirect_to edit_order_url(@order)
return
end
redirect_to (gateway.redirect_url_for response.token, :review => payment_method.preferred_review)
redirect_to (@gateway.redirect_url_for response.token, :review => payment_method.preferred_review)
rescue ActiveMerchant::ConnectionError => e
gateway_error I18n.t(:unable_to_connect_to_gateway)
redirect_to :back
@ -35,21 +29,21 @@ module Spree::PaypalExpress
load_order
opts = all_opts(@order,params[:payment_method_id], 'payment')
opts.merge!(address_options(@order))
gateway = paypal_gateway
@gateway = paypal_gateway
if Spree::Config[:auto_capture]
response = gateway.setup_purchase(opts[:money], opts)
@ppx_response = @gateway.setup_purchase(opts[:money], opts)
else
response = gateway.setup_authorization(opts[:money], opts)
@ppx_response = @gateway.setup_authorization(opts[:money], opts)
end
unless response.success?
gateway_error(response)
unless @ppx_response.success?
gateway_error(@ppx_response)
redirect_to edit_order_checkout_url(@order, :state => "payment")
return
end
redirect_to (gateway.redirect_url_for response.token, :review => payment_method.preferred_review)
redirect_to (@gateway.redirect_url_for @ppx_response.token, :review => payment_method.preferred_review)
rescue ActiveMerchant::ConnectionError => e
gateway_error I18n.t(:unable_to_connect_to_gateway)
redirect_to :back
@ -126,7 +120,6 @@ module Spree::PaypalExpress
ppx_auth_response = gateway.authorize((@order.total*100).to_i, opts)
end
if ppx_auth_response.success?
paypal_account = PaypalAccount.find_by_payer_id(params[:PayerID])
payment = @order.payments.create(
@ -137,24 +130,23 @@ module Spree::PaypalExpress
:response_code => ppx_auth_response.params["ack"],
:avs_response => ppx_auth_response.avs_result["code"])
# transition through the state machine. This way any callbacks can be made
payment.started_processing!
payment.pend!
record_log payment, ppx_auth_response
if ppx_auth_response.success?
#confirm status
case ppx_auth_response.params["payment_status"]
when "Completed"
payment.complete!
when "Pending"
payment.pend!
else
payment.pend!
Rails.logger.error "Unexpected response from PayPal Express"
Rails.logger.error ppx_auth_response.to_yaml
end
record_log payment, ppx_auth_response
@order.save!
#need to force checkout to complete state
until @order.state == "complete"
if @order.next!
@ -166,6 +158,7 @@ module Spree::PaypalExpress
redirect_to completion_route
else
payment.fail!
order_params = {}
gateway_error(ppx_auth_response)
@ -207,7 +200,7 @@ module Spree::PaypalExpress
{ :description => "Goods from #{Spree::Config[:site_name]}", # site details...
#:page_style => "foobar", # merchant account can set named config
:header_image => "https://" + Spree::Config[:site_url] + "/images/logo.png",
:header_image => "https://#{Spree::Config[:site_name]}/images/logo.png",
:background_color => "ffffff", # must be hex only, six chars
:header_background_color => "ffffff",
:header_border_color => "ffffff",
@ -253,7 +246,7 @@ module Spree::PaypalExpress
credits_total = 0
credits.compact!
if !credits.empty?
if credits.present?
items.concat credits
credits_total = credits.map {|i| i[:amount] * i[:qty] }.sum
end
@ -262,33 +255,23 @@ module Spree::PaypalExpress
:cancel_return_url => "http://" + request.host_with_port + "/orders/#{order.number}/edit",
:order_id => order.number,
:custom => order.number,
:items => items
}
:items => items,
:subtotal => ((order.item_total * 100) + credits_total).to_i,
:tax => ((order.adjustments.map { |a| a.amount if ( a.source_type == 'Order' && a.label == 'Tax') }.compact.sum) * 100 ).to_i,
:shipping => ((order.adjustments.map { |a| a.amount if a.source_type == 'Shipment' }.compact.sum) * 100 ).to_i,
:money => (order.total * 100 ).to_i }
if stage == "checkout"
# recalculate all totals here as we need to ignore shipping & tax because we are checking-out via paypal (spree checkout not started)
opts[:subtotal] = ((order.item_total * 100) + credits_total ).to_i
opts[:handling] = 0
opts[:tax] = ((order.adjustments.map { |a| a.amount if ( a.source_type == 'Order' && a.label == 'Tax') }.compact.sum) * 100 ).to_i
opts[:shipping] = ((order.adjustments.map { |a| a.amount if a.source_type == 'Shipment' }.compact.sum) * 100 ).to_i
# overall total
opts[:money] = (order.total * 100 ).to_i
opts[:callback_url] = "http://" + request.host_with_port + "/paypal_express_callbacks/#{order.number}"
opts[:callback_timeout] = 3
elsif stage == "payment"
#use real totals are we are paying via paypal (spree checkout almost complete)
opts[:subtotal] = ((order.item_total * 100) + (order.credits.total * 100)).to_i
opts[:tax] = ((order.adjustments.map { |a| a.amount if ( a.source_type == 'Order' && a.label == 'Tax') }.compact.sum) * 100 ).to_i
opts[:shipping] = ((order.adjustments.map { |a| a.amount if a.source_type == 'Shipment' }.compact.sum) * 100 ).to_i
#hack to add float rounding difference in as handling fee - prevents PayPal from rejecting orders
#because the integer totals are different from the float based total. This is temporary and will be
#removed once Spree's currency values are persisted as integers (normally only 1c)
opts[:handling] = (order.total*100).to_i - opts.slice(:subtotal, :tax, :shipping).values.sum
opts[:money] = (order.total*100).to_i
end
opts
@ -374,4 +357,6 @@ module Spree::PaypalExpress
def paypal_gateway
payment_method.provider
end
end

View File

@ -5,16 +5,20 @@ class PaypalExpressCallbacksController < Spree::BaseController
def notify
retrieve_details #need to retreive details first to ensure ActiveMerchant gets configured correctly.
@notification = Paypal::Notification.new(request.raw_post)
# we only care about eChecks (for now?)
if @notification.params["payment_type"] == "echeck" && @notification.acknowledge && @payment && @order.total >= @payment.amount
@payment.started_processing!
@payment.log_entries.create(:details => @notification.to_yaml)
case @notification.params["payment_status"]
when "Denied"
create_txn PaypalTxn::TxnType::DENIED
@payment.fail!
when "Completed"
create_txn PaypalTxn::TxnType::CAPTURE
@payment.complete!
end
end
@ -27,31 +31,10 @@ class PaypalExpressCallbacksController < Spree::BaseController
@order = Order.find_by_number(params["invoice"])
if @order
@payment = @order.checkout.payments.find(:first,
:conditions => {"transactions.txn_type" => PaypalTxn::TxnType::AUTHORIZE,
"transactions.payment_type" => params["payment_type"]},
:joins => :transactions)
@payment = @order.payments.where(:state => "pending", :source_type => "PaypalAccount").try(:first)
@payment.try(:payment_method).try(:provider) #configures ActiveMerchant
end
end
def create_txn(txn_type)
if txn_type == PaypalTxn::TxnType::CAPTURE
@payment.finalize! if @payment.can_finalize?
elsif txn_type == PaypalTxn::TxnType::DENIED
#maybe we should do something?
end
PaypalTxn.create(:payment => @payment,
:txn_type => txn_type,
:amount => @notification.params["payment_gross"].to_f,
:payment_status => @notification.params["payment_status"],
:transaction_id => @notification.params["txn_id"],
:transaction_type => @notification.params["txn_type"],
:payment_type => @notification.params["payment_type"])
end
end

View File

@ -32,8 +32,10 @@
<legend><%= t('transactions') %></legend>
<% payment.log_entries.reverse.each do |log| %>
<% details = YAML.load(log.details) %>
<% details = YAML.load(log.details) rescue "" %>
<table class="index">
<% if details.is_a? ActiveMerchant::Billing::PaypalExpressResponse %>
<tr>
<th colspan="6"><%= t('transaction') %> <%= details.params["transaction_id"] %> - <%= log.created_at.to_s(:date_time24) %></th>
</tr>
@ -69,6 +71,39 @@
</td>
</tr>
<% end %>
<% elsif details.is_a? ActiveMerchant::Billing::Integrations::Paypal::Notification %>
<tr>
<th colspan="6"><%= t('ipn_transaction') %> <%= details.params["txn_id"] %> - <%= log.created_at.to_s(:date_time24) %></th>
</tr>
<tr>
<td width="12%;"><label><%= t('type') %>:</label></td>
<td width="20%;">
<%= details.params["txn_type"] %>
</td>
<td width="8%;"><label><%= t("result") %>:</label></td>
<td width="20%;">
<%= details.params["payment_status"] %>
</td>
<td width="15%;"><label><%= t("amount") %>:</label></td>
<td width="20%;">
<%= number_to_currency details.params["mc_gross"] %>
</td>
</tr>
<tr>
<td><label><%= t("status") %>:</label></td>
<td colspan="5">
<%= details.params["payment_status"] %>
</td>
</tr>
<% else %>
<tr>
<th colspan="6"><%= t('unknown_transaction') %> - <%= log.created_at.to_s(:date_time24) %></th>
</tr>
<tr>
<td colspan="6"><pre style="overflow: hidden; width:600px;"><%= log.details %></pre></th>
</tr>
<% end %>
</table>
<% end %>

View File

@ -1,5 +1,7 @@
---
en:
ipn_transaction: IPN Transaction
unknown_transaction: Unknown Transaction
edit_paypal_info: Edit Paypal Express Payment
paypal_payment: Paypal Express Payment
paypal_txn_id: Transaction Code

View File

@ -12,7 +12,6 @@ Rails.application.routes.draw do
match '/paypal_notify' => 'paypal_express_callbacks#notify', :via => [:get, :post]
resources :paypal_express_callbacks
namespace :admin do
resources :orders do
resources :paypal_payments do

View File

@ -1,22 +0,0 @@
class CreatePaypalTxns < ActiveRecord::Migration
def self.up
create_table :paypal_txns do |t|
t.references :paypal_payment
t.decimal :gross_amount, :precision => 8, :scale => 2
t.string :payment_status
t.text :message
t.string :pending_reason
t.string :transaction_type
t.string :payment_type
t.string :ack
t.string :token
t.string :avs_code
t.string :cvv_code
t.timestamps
end
end
def self.down
drop_table :paypal_txns
end
end

View File

@ -1,11 +0,0 @@
class RenameAvsCode < ActiveRecord::Migration
def self.up
rename_column :paypal_txns, :avs_code, :avs_response
rename_column :paypal_txns, :cvv_code, :cvv_response
end
def self.down
rename_column :paypal_txns, :cvv_response, :cvv_code
rename_column :paypal_txns, :avs_response, :avs_code
end
end

View File

@ -1,9 +0,0 @@
class AddTransactionIdToPpxTxn < ActiveRecord::Migration
def self.up
add_column :paypal_txns, :transaction_id, :string
end
def self.down
remove_column :paypal_txns, :transaction_id
end
end

View File

@ -1,361 +0,0 @@
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
# This module is included in both PaypalGateway and PaypalExpressGateway
module PaypalCommonAPI
def self.included(base)
base.default_currency = 'USD'
base.cattr_accessor :pem_file
base.cattr_accessor :signature
end
silence_warnings do
API_VERSION = '60.0'
end
# The gateway must be configured with either your PayPal PEM file
# or your PayPal API Signature. Only one is required.
#
# <tt>:pem</tt> The text of your PayPal PEM file. Note
# this is not the path to file, but its
# contents. If you are only using one PEM
# file on your site you can declare it
# globally and then you won't need to
# include this option
#
# <tt>:signature</tt> The text of your PayPal signature.
# If you are only using one API Signature
# on your site you can declare it
# globally and then you won't need to
# include this option
def initialize(options = {})
requires!(options, :login, :password)
@options = {
:pem => pem_file,
:signature => signature
}.update(options)
if @options[:pem].blank? && @options[:signature].blank?
raise ArgumentError, "An API Certificate or API Signature is required to make requests to PayPal"
end
super
end
def test?
@options[:test] || Base.gateway_mode == :test
end
def reauthorize(money, authorization, options = {})
commit 'DoReauthorization', build_reauthorize_request(money, authorization, options)
end
def capture(money, authorization, options = {})
commit 'DoCapture', build_capture_request(money, authorization, options)
end
# Transfer money to one or more recipients.
#
# gateway.transfer 1000, 'bob@example.com',
# :subject => "The money I owe you", :note => "Sorry it's so late"
#
# gateway.transfer [1000, 'fred@example.com'],
# [2450, 'wilma@example.com', :note => 'You will receive another payment on 3/24'],
# [2000, 'barney@example.com'],
# :subject => "Your Earnings", :note => "Thanks for your business."
#
def transfer(*args)
commit 'MassPay', build_mass_pay_request(*args)
end
def void(authorization, options = {})
commit 'DoVoid', build_void_request(authorization, options)
end
def credit(money, identification, options = {})
commit 'RefundTransaction', build_credit_request(money, identification, options)
end
private
def build_reauthorize_request(money, authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoReauthorizationReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoReauthorizationRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
end
end
xml.target!
end
def build_capture_request(money, authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoCaptureReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoCaptureRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'CompleteType', 'Complete'
xml.tag! 'Note', options[:description]
end
end
xml.target!
end
def build_credit_request(money, identification, options)
xml = Builder::XmlMarkup.new
xml.tag! 'RefundTransactionReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'RefundTransactionRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'TransactionID', identification
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'RefundType', 'Partial'
xml.tag! 'Memo', options[:note] unless options[:note].blank?
end
end
xml.target!
end
def build_void_request(authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoVoidReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoVoidRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Note', options[:description]
end
end
xml.target!
end
def build_mass_pay_request(*args)
default_options = args.last.is_a?(Hash) ? args.pop : {}
recipients = args.first.is_a?(Array) ? args : [args]
xml = Builder::XmlMarkup.new
xml.tag! 'MassPayReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'MassPayRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'EmailSubject', default_options[:subject] if default_options[:subject]
recipients.each do |money, recipient, options|
options ||= default_options
xml.tag! 'MassPayItem' do
xml.tag! 'ReceiverEmail', recipient
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'Note', options[:note] if options[:note]
xml.tag! 'UniqueId', options[:unique_id] if options[:unique_id]
end
end
end
end
xml.target!
end
def parse(action, xml)
response = {}
error_messages = []
error_codes = []
xml = REXML::Document.new(xml)
if root = REXML::XPath.first(xml, "//#{action}Response")
root.elements.each do |node|
case node.name
when 'Errors'
short_message = nil
long_message = nil
node.elements.each do |child|
case child.name
when "LongMessage"
long_message = child.text unless child.text.blank?
when "ShortMessage"
short_message = child.text unless child.text.blank?
when "ErrorCode"
error_codes << child.text unless child.text.blank?
end
end
if message = long_message || short_message
error_messages << message
end
else
parse_element(response, node)
end
end
response[:message] = error_messages.uniq.join(". ") unless error_messages.empty?
response[:error_codes] = error_codes.uniq.join(",") unless error_codes.empty?
elsif root = REXML::XPath.first(xml, "//SOAP-ENV:Fault")
parse_element(response, root)
response[:message] = "#{response[:faultcode]}: #{response[:faultstring]} - #{response[:detail]}"
end
response
end
def parse_element(response, node)
if node.has_elements?
node.elements.each{|e| parse_element(response, e) }
else
response[node.name.underscore.to_sym] = node.text
node.attributes.each do |k, v|
response["#{node.name.underscore}_#{k.underscore}".to_sym] = v if k == 'currencyID'
end
end
end
def build_request(body)
xml = Builder::XmlMarkup.new
xml.instruct!
xml.tag! 'env:Envelope', ENVELOPE_NAMESPACES do
xml.tag! 'env:Header' do
add_credentials(xml)
end
xml.tag! 'env:Body' do
xml << body
end
end
xml.target!
end
def add_credentials(xml)
xml.tag! 'RequesterCredentials', CREDENTIALS_NAMESPACES do
xml.tag! 'n1:Credentials' do
xml.tag! 'Username', @options[:login]
xml.tag! 'Password', @options[:password]
xml.tag! 'Subject', @options[:subject]
xml.tag! 'Signature', @options[:signature] unless @options[:signature].blank?
end
end
end
def add_address(xml, element, address)
return if address.nil?
xml.tag! element do
xml.tag! 'n2:Name', address[:name]
xml.tag! 'n2:Street1', address[:address1]
xml.tag! 'n2:Street2', address[:address2]
xml.tag! 'n2:CityName', address[:city]
xml.tag! 'n2:StateOrProvince', address[:state].blank? ? 'N/A' : address[:state]
xml.tag! 'n2:Country', address[:country]
xml.tag! 'n2:PostalCode', address[:zip]
xml.tag! 'n2:Phone', address[:phone]
end
end
def add_payment_detail_item(xml, item, options)
currency_code = options[:currency] || currency(item[:amount])
xml.tag! 'n2:PaymentDetailsItem' do
xml.tag! 'n2:Name', item[:name] unless item[:name].blank?
xml.tag! 'n2:Description', item[:description] unless item[:description].blank?
xml.tag! 'n2:Number', item[:sku] unless item[:sku].blank?
xml.tag! 'n2:Quantity', item[:qty] unless item[:qty].blank?
if item[:amount].to_i > 0
xml.tag! 'n2:Amount', amount(item[:amount]), 'currencyID' => currency_code unless item[:amount].blank?
else
xml.tag! 'n2:Amount', "-#{amount(item[:amount].to_i*-1)}", 'currencyID' => currency_code unless item[:amount].blank?
end
xml.tag! 'n2:Tax', amount(item[:tax]), 'currencyID' => currency_code unless item[:tax].blank?
xml.tag! 'n2:ItemWeight', item[:weight] unless item[:weight].blank?
xml.tag! 'n2:ItemHeight', item[:height] unless item[:height].blank?
xml.tag! 'n2:ItemWidth', item[:width] unless item[:width].blank?
xml.tag! 'n2:ItemLength', item[:length] unless item[:length].blank?
# not doing this yet TODO
# xml.tag! 'n2:EbayItemPaymentDetailsItem', item[:name]
end
end
def add_payment_details(xml, money, options)
currency_code = options[:currency] || currency(money)
xml.tag! 'n2:PaymentDetails' do
xml.tag! 'n2:OrderTotal', amount(money), 'currencyID' => currency_code
# All of the values must be included together and add up to the order total
if [:subtotal, :shipping, :handling, :tax].all?{ |o| options.has_key?(o) }
xml.tag! 'n2:ItemTotal', amount(options[:subtotal]), 'currencyID' => currency_code
xml.tag! 'n2:ShippingTotal', amount(options[:shipping]),'currencyID' => currency_code
xml.tag! 'n2:HandlingTotal', amount(options[:handling]),'currencyID' => currency_code
xml.tag! 'n2:TaxTotal', amount(options[:tax]), 'currencyID' => currency_code
end
# don't enforce inclusion yet - see how it works
xml.tag! 'n2:InsuranceOptionOffered', options[:insurance_offered] ? '1' : '0' unless options[:insurance_offered].blank?
xml.tag! 'n2:InsuranceTotal', amount(options[:insurance]), 'currencyID' => currency_code unless options[:insurance].blank?
xml.tag! 'n2:ShippingDiscount', amount(options[:ship_discount]), 'currencyID' => currency_code unless options[:ship_discount].blank?
# query - use slices too? or just risk reject? (QQ: injection risk???)
xml.tag! 'n2:OrderDescription', options[:description] unless options[:description].blank?
xml.tag! 'n2:Custom', options[:custom] unless options[:custom].blank?
xml.tag! 'n2:InvoiceID', options[:order_id] unless options[:order_id].blank?
xml.tag! 'n2:ButtonSource', application_id.to_s.slice(0,32) unless application_id.blank?
xml.tag! 'n2:NotifyURL', options[:notify_url] unless options[:notify_url].blank?
add_address(xml, 'n2:ShipToAddress', options[:shipping_address] || options[:address])
options[:items].each {|i| add_payment_detail_item xml, i, options } if options[:items]
end
end
def add_shipping_options(xml, shipping_options, options)
currency_code = options[:currency]
xml.tag! 'n2:FlatRateShippingOptions' do
shipping_options.each_with_index do |shipping_option, i|
xml.tag! 'n2:ShippingOptions' do
xml.tag! 'n2:ShippingOptionIsDefault', (i == 0)
xml.tag! 'n2:ShippingOptionName', shipping_option[:name]
xml.tag! 'n2:ShippingOptionLabel', shipping_option[:label]
xml.tag! 'n2:ShippingOptionAmount', amount(shipping_option[:amount] ), 'currencyID' => 'USD'
end
end
end
end
def endpoint_url
URLS[test? ? :test : :live][@options[:signature].blank? ? :certificate : :signature]
end
def commit(action, request)
response = parse(action, ssl_post(endpoint_url, build_request(request)))
build_response(successful?(response), message_from(response), response,
:test => test?,
:authorization => authorization_from(response),
:fraud_review => fraud_review?(response),
:avs_result => { :code => response[:avs_code] },
:cvv_result => response[:cvv2_code]
)
end
def fraud_review?(response)
response[:error_codes] == FRAUD_REVIEW_CODE
end
def authorization_from(response)
response[:transaction_id] || response[:authorization_id] || response[:refund_transaction_id] # middle one is from reauthorization
end
def successful?(response)
SUCCESS_CODES.include?(response[:ack])
end
def message_from(response)
response[:message] || response[:ack]
end
end
end
end

View File

@ -1,128 +0,0 @@
#require File.dirname(__FILE__) + '/paypal/paypal_common_api'
#require File.dirname(__FILE__) + '/paypal/paypal_express_response'
#require File.dirname(__FILE__) + '/paypal_express_common'
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
class PaypalExpressGateway < Gateway
include PaypalCommonAPI
include PaypalExpressCommon
self.test_redirect_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token='
self.supported_countries = ['US']
self.homepage_url = 'https://www.paypal.com/cgi-bin/webscr?cmd=xpt/merchant/ExpressCheckoutIntro-outside'
self.display_name = 'PayPal Express Checkout'
def setup_authorization(money, options = {})
requires!(options, :return_url, :cancel_return_url)
commit 'SetExpressCheckout', build_setup_request('Authorization', money, options)
end
def setup_purchase(money, options = {})
requires!(options, :return_url, :cancel_return_url)
commit 'SetExpressCheckout', build_setup_request('Sale', money, options)
end
def details_for(token)
commit 'GetExpressCheckoutDetails', build_get_details_request(token)
end
def authorize(money, options = {})
requires!(options, :token, :payer_id)
commit 'DoExpressCheckoutPayment', build_sale_or_authorization_request('Authorization', money, options)
end
def purchase(money, options = {})
requires!(options, :token, :payer_id)
commit 'DoExpressCheckoutPayment', build_sale_or_authorization_request('Sale', money, options)
end
private
def build_get_details_request(token)
xml = Builder::XmlMarkup.new :indent => 2
xml.tag! 'GetExpressCheckoutDetailsReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'GetExpressCheckoutDetailsRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'Token', token
end
end
xml.target!
end
def build_sale_or_authorization_request(action, money, options)
currency_code = options[:currency] || currency(money)
xml = Builder::XmlMarkup.new :indent => 2
xml.tag! 'DoExpressCheckoutPaymentReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoExpressCheckoutPaymentRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'n2:DoExpressCheckoutPaymentRequestDetails' do
xml.tag! 'n2:PaymentAction', action
xml.tag! 'n2:Token', options[:token]
xml.tag! 'n2:PayerID', options[:payer_id]
add_payment_details(xml, money, options)
end
end
end
xml.target!
end
def build_setup_request(action, money, options)
xml = Builder::XmlMarkup.new :indent => 2
xml.tag! 'SetExpressCheckoutReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'SetExpressCheckoutRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'n2:SetExpressCheckoutRequestDetails' do
if options[:max_amount]
xml.tag! 'n2:MaxAmount', amount(options[:max_amount]), 'currencyID' => options[:currency] || currency(options[:max_amount])
end
xml.tag! 'n2:ReturnURL', options[:return_url]
xml.tag! 'n2:CancelURL', options[:cancel_return_url]
# xml.tag! 'n2:CallbackURL', options[:callback_url] unless options[:callback_url].blank?
# xml.tag! 'n2:CallbackTimeout', options[:callback_timeout] unless options[:callback_timeout].blank?
xml.tag! 'n2:ReqConfirmShipping', options[:req_confirm_shipping] ? '1' : '0'
xml.tag! 'n2:NoShipping', options[:no_shipping] ? '1' : '0'
## add flat rates for shipping
# add_shipping_options(xml, options[:shipping_options], options) if options[:shipping_options]
xml.tag! 'n2:AllowNote', options[:allow_note] ? '1' : '0'
xml.tag! 'n2:AddressOverride', options[:address_override] ? '1' : '0' # force yours
xml.tag! 'n2:LocaleCode', options[:locale] unless options[:locale].blank?
# Customization of the payment page
xml.tag! 'n2:PageStyle', options[:page_style] unless options[:page_style].blank?
xml.tag! 'n2:cpp-header-image', options[:header_image] unless options[:header_image].blank?
xml.tag! 'n2:cpp-header-border-color', options[:header_border_color] unless options[:header_border_color].blank?
xml.tag! 'n2:cpp-header-back-color', options[:header_background_color] unless options[:header_background_color].blank?
xml.tag! 'n2:cpp-payflow-color', options[:background_color] unless options[:background_color].blank?
xml.tag! 'n2:PaymentAction', action
xml.tag! 'n2:BuyerEmail', options[:email] unless options[:email].blank?
xml.tag! 'n2:SolutionType', options[:solution_type] unless options[:solution_type].blank?
xml.tag! 'n2:LandingPage', options[:landing_page] unless options[:landing_page].blank?
xml.tag! 'n2:ChannelType', options[:channel_type] unless options[:channel_type].blank?
# only needed for certain methods in Germany
xml.tag! 'n2:giropaySuccessURL', options[:giropay_url] unless options[:giropay_url].blank?
xml.tag! 'n2:giropayCancelURL', options[:giropay_cancel_url] unless options[:giropay_cancel_url].blank?
xml.tag! 'n2:BanktxnPendingURL', options[:banktxn_url] unless options[:banktxn_url].blank?
# for order values etc, and item info
add_payment_details(xml, money, options)
end
end
end
xml.target!
end
def build_response(success, message, response, options = {})
PaypalExpressResponse.new(success, message, response, options)
end
end
end
end

View File

@ -1,14 +0,0 @@
require File.dirname(__FILE__) + '/paypal_express'
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
class PaypalExpressUkGateway < PaypalExpressGateway
self.default_currency = 'GBP'
self.supported_countries = ['GB']
self.homepage_url = 'https://www.paypal.com/uk/cgi-bin/webscr?cmd=_additional-payment-overview-outside'
self.display_name = 'PayPal Express Checkout (UK)'
end
end
end

View File

@ -1,7 +0,0 @@
module Spree::CheckoutControllerWithPaypalExpress
def self.included(target)
target.before_filter :redirect_to_paypal_express_form, :only => [:update]
end
private
end

View File

@ -7,21 +7,16 @@ module SpreePaypalExpress
config.autoload_paths += %W(#{config.root}/lib)
def self.activate
#workaround for https://github.com/Shopify/active_merchant/issuesearch?state=open&q=paypal#issue/43
require 'active_merchant'
ActiveMerchant::Billing::PaypalExpressGateway
Dir.glob(File.join(File.dirname(__FILE__), "../app/**/*_decorator*.rb")) do |c|
Rails.env.production? ? require(c) : load(c)
end
BillingIntegration::PaypalExpress.register
BillingIntegration::PaypalExpressUk.register
# Load up over-rides for ActiveMerchant files
# these will be submitted to ActiveMerchant some time...
require File.join(File.dirname(__FILE__), "active_merchant", "billing", "gateways", "paypal", "paypal_common_api.rb")
require File.join(File.dirname(__FILE__), "active_merchant", "billing", "gateways", "paypal_express_uk.rb")
# inject paypal code into orders controller
CheckoutController.class_eval do
include Spree::PaypalExpress
end
end
config.to_prepare &method(:activate).to_proc

View File

@ -0,0 +1,278 @@
require File.dirname(__FILE__) + '/../spec_helper'
describe CheckoutController do
let(:token) { "EC-2OPN7UJGFWK9OYFV" }
let(:order) { Factory(:ppx_order_with_totals, :state => "payment") }
let(:order_total) { (order.total * 100).to_i }
let(:gateway_provider) { mock(ActiveMerchant::Billing::PaypalExpressGateway) }
let(:paypal_gateway) { mock(BillingIntegration::PaypalExpress, :id => 123, :preferred_review => false, :preferred_no_shipping => true, :provider => gateway_provider) }
let(:details_for_response) { mock(ActiveMerchant::Billing::PaypalExpressResponse, :success? => true,
:params => {"payer" => order.user.email, "payer_id" => "FWRVKNRRZ3WUC"}, :address => {}) }
let(:purchase_response) { mock(ActiveMerchant::Billing::PaypalExpressResponse, :success? => true,
:params => {"payer" => order.user.email, "payer_id" => "FWRVKNRRZ3WUC", "gross_amount" => order_total, "payment_status" => "Completed"},
:avs_result => "F",
:to_yaml => "fake") }
before do
Spree::Auth::Config.set(:registration_step => false)
controller.stub(:current_order => order, :check_authorization => true, :current_user => order.user)
order.stub(:checkout_allowed? => true, :completed? => false)
order.update!
end
it "should understand paypal routes" do
assert_routing("/orders/#{order.number}/checkout/paypal_payment", {:controller => "checkout", :action => "paypal_payment", :order_id => order.number })
assert_routing("/orders/#{order.number}/checkout/paypal_confirm", {:controller => "checkout", :action => "paypal_confirm", :order_id => order.number })
end
context "paypal_checkout" do
#feature not implemented
end
context "paypal_payment without auto_capture" do
let(:redirect_url) { "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=#{token}&useraction=commit" }
before { Spree::Config.set(:auto_capture => false) }
it "should setup an authorize transaction and redirect to sandbox" do
PaymentMethod.should_receive(:find).at_least(1).with('123').and_return(paypal_gateway)
gateway_provider.should_receive(:redirect_url_for).with(token, {:review => false}).and_return redirect_url
paypal_gateway.provider.should_receive(:setup_authorization).with(order_total, anything()).and_return(mock(:success? => true, :token => token))
get :paypal_payment, {:order_id => order.number, :payment_method_id => "123" }
response.should redirect_to "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=#{assigns[:ppx_response].token}&useraction=commit"
end
end
context "paypal_payment with auto_capture" do
let(:redirect_url) { "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=#{token}&useraction=commit" }
before { Spree::Config.set(:auto_capture => true) }
it "should setup a purchase transaction and redirect to sandbox" do
PaymentMethod.should_receive(:find).at_least(1).with("123").and_return(paypal_gateway)
gateway_provider.should_receive(:redirect_url_for).with(token, {:review => false}).and_return redirect_url
paypal_gateway.provider.should_receive(:setup_purchase).with(order_total, anything()).and_return(mock(:success? => true, :token => token))
get :paypal_payment, {:order_id => order.number, :payment_method_id => "123" }
response.should redirect_to "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=#{assigns[:ppx_response].token}&useraction=commit"
end
end
context "paypal_confirm" do
before { PaymentMethod.should_receive(:find).at_least(1).with("123").and_return(paypal_gateway) }
context "with auto_capture and no review" do
before do
Spree::Config.set(:auto_capture => true)
paypal_gateway.stub(:preferred_review => false)
end
it "should capture payment" do
paypal_gateway.provider.should_receive(:details_for).with(token).and_return(details_for_response)
paypal_gateway.provider.should_receive(:purchase).with(order_total, anything()).and_return(purchase_response)
get :paypal_confirm, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to order_url(order)
order.reload
order.state.should == "complete"
order.payments.size.should == 1
order.payment_state.should == "paid"
end
end
context "with review" do
before { paypal_gateway.stub(:preferred_review => true) }
it "should render review" do
paypal_gateway.provider.should_receive(:details_for).with(token).and_return(details_for_response)
get :paypal_confirm, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should render_template("shared/paypal_express_confirm")
end
end
context "with review and shipping update" do
before do
paypal_gateway.stub(:preferred_review => true)
paypal_gateway.stub(:preferred_no_shipping => false)
details_for_response.stub(:params => details_for_response.params.merge({'first_name' => 'Dr.', 'last_name' => 'Evil'}),
:address => {'address1' => 'Apt. 187', 'address2'=> 'Some Str.', 'city' => 'Chevy Chase', 'country' => 'US', 'zip' => '20815', 'state' => 'MD' })
end
it "should update ship_address and render review" do
paypal_gateway.provider.should_receive(:details_for).with(token).and_return(details_for_response)
get :paypal_confirm, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
order.ship_address.address1.should == "Apt. 187"
response.should render_template("shared/paypal_express_confirm")
end
end
context "with un-successful repsonse" do
before { details_for_response.stub(:success? => false) }
it "should log error and redirect to payment step" do
paypal_gateway.provider.should_receive(:details_for).with(token).and_return(details_for_response)
controller.should_receive(:gateway_error).with(details_for_response)
get :paypal_confirm, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to edit_order_checkout_url(order, :state => 'payment')
end
end
end
context "paypal_finish" do
let(:paypal_account) { stub_model(PaypalAccount, :payer_id => "FWRVKNRRZ3WUC", :email => order.email ) }
let(:authorize_response) { mock(ActiveMerchant::Billing::PaypalExpressResponse, :success? => true,
:params => {"payer" => order.user.email, "payer_id" => "FWRVKNRRZ3WUC", "gross_amount" => order_total, "payment_status" => "Pending"},
:avs_result => "F",
:to_yaml => "fake") }
before do
PaymentMethod.should_receive(:find).at_least(1).with("123").and_return(paypal_gateway)
PaypalAccount.should_receive(:find_by_payer_id).with("FWRVKNRRZ3WUC").and_return(paypal_account)
end
context "with auto_capture" do
before { Spree::Config.set(:auto_capture => true) }
it "should capture payment" do
paypal_gateway.provider.should_receive(:purchase).with(order_total, anything()).and_return(purchase_response)
get :paypal_finish, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to order_url(order)
order.reload
order.update!
order.payments.size.should == 1
order.payment_state.should == "paid"
end
end
context "with auto_capture and pending(echeck) response" do
before do
Spree::Config.set(:auto_capture => true)
purchase_response.params["payment_status"] = "pending"
end
it "should authorize payment" do
paypal_gateway.provider.should_receive(:purchase).with(order_total, anything()).and_return(purchase_response)
get :paypal_finish, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to order_url(order)
order.reload
order.update!
order.payments.size.should == 1
order.payment_state.should == "balance_due"
order.payment.state.should == "pending"
end
end
context "without auto_capture" do
before { Spree::Config.set(:auto_capture => false) }
it "should authorize payment" do
paypal_gateway.provider.should_receive(:authorize).with(order_total, anything()).and_return(authorize_response)
get :paypal_finish, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to order_url(order)
order.reload
order.update!
order.payments.size.should == 1
order.payment_state.should == "balance_due"
order.payment.state.should == "pending"
end
end
context "with un-successful repsonse" do
before do
Spree::Config.set(:auto_capture => true)
purchase_response.stub(:success? => false)
end
it "should log error and redirect to payment step" do
paypal_gateway.provider.should_receive(:purchase).with(order_total, anything()).and_return(purchase_response)
controller.should_receive(:gateway_error).with(purchase_response)
get :paypal_finish, {:order_id => order.number, :payment_method_id => "123", :token => token, :PayerID => "FWRVKNRRZ3WUC" }
response.should redirect_to edit_order_checkout_url(order, :state => 'payment')
order.reload
order.update!
order.payments.size.should == 1
order.payment_state.should == "failed"
order.payment.state.should == "failed"
end
end
end
context "order_opts" do
it "should return hash containing basic order details" do
opts = controller.send(:order_opts, order, paypal_gateway.id, 'payment')
opts.class.should == Hash
opts[:money].should == order_total
opts[:subtotal].should == (order.item_total * 100).to_i
opts[:order_id].should == order.number
opts[:custom].should == order.number
opts[:handling].should == 0
opts[:shipping].should == (order.ship_total * 100).to_i
opts[:return_url].should == paypal_confirm_order_checkout_url(order, :payment_method_id => paypal_gateway.id)
opts[:cancel_return_url].should == edit_order_url(order)
opts[:items].size.should > 0
opts[:items].size.should == order.line_items.count
end
it "should include credits in returned hash" do
order_total #need here so variable is set before credit is created.
order.adjustments.create(:label => "Credit", :amount => -1)
order.update!
opts = controller.send(:order_opts, order, paypal_gateway.id, 'payment')
opts.class.should == Hash
opts[:money].should == order_total - 100
opts[:subtotal].should == ((order.item_total * 100) + (order.adjustments.select{|c| c.amount < 0}.sum(&:amount) * 100)).to_i
opts[:items].size.should == order.line_items.count + 1
end
end
end

View File

@ -0,0 +1,13 @@
Factory.define :ppx_address do |f|
f.firstname 'John'
f.lastname 'Doe'
f.address1 '10 Lovely Street'
f.address2 'Northwest'
f.city "Herndon"
f.state { |state| state.association(:ppx_state) }
f.zipcode '20170'
f.country { |country| country.association(:country) }
f.phone '123-456-7890'
f.state_name "maryland"
f.alternative_phone "123-456-7899"
end

View File

@ -0,0 +1,11 @@
Factory.define(:ppx_order) do |record|
# associations:
record.association(:user, :factory => :user)
record.association(:bill_address, :factory => :address)
record.association(:shipping_method, :factory => :shipping_method)
record.ship_address { |ship_address| Factory(:ppx_address, :city => "Chevy Chase", :zipcode => "20815") }
end
Factory.define :ppx_order_with_totals, :parent => :order do |f|
f.after_create { |order| Factory(:line_item, :order => order) }
end

View File

@ -0,0 +1,5 @@
Factory.define :md_state do |f|
f.name 'Maryland'
f.abbr 'MD'
f.country { |country| country.association(:country) }
end

View File

@ -1,13 +1,17 @@
# This file is copied to ~/spec when you run 'ruby script/generate rspec'
# from the project root directory.
ENV["RAILS_ENV"] ||= 'test'
require File.expand_path("../../../config/environment", __FILE__)
require File.expand_path("../test_app/config/environment", __FILE__)
require 'rspec/rails'
require 'fabrication'
# Requires supporting files with custom matchers and macros, etc,
# in ./support/ and its subdirectories.
Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
#include spree's factories
require File.join(ENV['SPREE_PATH'], 'core/spec/factories')
# include local factories
Dir["#{File.dirname(__FILE__)}/factories/**/*.rb"].each do |f|
fp = File.expand_path(f)
require fp
end
RSpec.configure do |config|
# == Mock Framework
@ -21,11 +25,16 @@ RSpec.configure do |config|
config.fixture_path = "#{::Rails.root}/spec/fixtures"
#config.include Devise::TestHelpers, :type => :controller
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, comment the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
end
Zone.class_eval do
def self.global
find_by_name("GlobalZone") || Factory(:global_zone)
end
end
@configuration ||= AppConfiguration.find_or_create_by_name("Default configuration")