diff --git a/.gitignore b/.gitignore index 1377554..a058759 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.swp +spec/test_app diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e7aa611 --- /dev/null +++ b/Gemfile @@ -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 diff --git a/README.markdown b/README.markdown index 167d34c..2926074 100644 --- a/README.markdown +++ b/README.markdown @@ -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. @@ -23,17 +24,7 @@ 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,10 +115,10 @@ 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) end -[1] If you check the review checkbox in the admin section for Payment Methods/Paypal Express, the flow is slightly different. Instead of Pay Now on Paypal's order details page, it now says Continue. And the user is directed back to the spree app's Confirmation page showing a place order button. Use whichever suits your needs best. Personally, I leave review unchecked to cut down on the steps in the checkout flow. \ No newline at end of file +[1] If you check the review checkbox in the admin section for Payment Methods/Paypal Express, the flow is slightly different. Instead of Pay Now on Paypal's order details page, it now says Continue. And the user is directed back to the spree app's Confirmation page showing a place order button. Use whichever suits your needs best. Personally, I leave review unchecked to cut down on the steps in the checkout flow. diff --git a/Rakefile b/Rakefile index 09dbc4e..527ba13 100644 --- a/Rakefile +++ b/Rakefile @@ -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 \ No newline at end of file diff --git a/lib/spree/paypal_express.rb b/app/controllers/checkout_controller_decorator.rb similarity index 81% rename from lib/spree/paypal_express.rb rename to app/controllers/checkout_controller_decorator.rb index fef5499..cc78b68 100644 --- a/lib/spree/paypal_express.rb +++ b/app/controllers/checkout_controller_decorator.rb @@ -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,10 +120,9 @@ 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]) + paypal_account = PaypalAccount.find_by_payer_id(params[:PayerID]) - payment = @order.payments.create( + payment = @order.payments.create( :amount => ppx_auth_response.params["gross_amount"].to_f, :source => paypal_account, :source_type => 'PaypalAccount', @@ -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! + payment.started_processing! + 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", @@ -252,43 +245,33 @@ module Spree::PaypalExpress end credits_total = 0 - credits.compact! - if !credits.empty? - items.concat credits - credits_total = credits.map {|i| i[:amount] * i[:qty] }.sum - end + credits.compact! + if credits.present? + items.concat credits + credits_total = credits.map {|i| i[:amount] * i[:qty] }.sum + end opts = { :return_url => request.protocol + request.host_with_port + "/orders/#{order.number}/checkout/paypal_confirm?payment_method_id=#{payment_method}", :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 - + elsif stage == "payment" #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 + diff --git a/app/controllers/paypal_express_callbacks_controller.rb b/app/controllers/paypal_express_callbacks_controller.rb index 635e367..b1c6506 100644 --- a/app/controllers/paypal_express_callbacks_controller.rb +++ b/app/controllers/paypal_express_callbacks_controller.rb @@ -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 diff --git a/app/views/admin/payments/source_views/_paypalexpress.html.erb b/app/views/admin/payments/source_views/_paypalexpress.html.erb index 0b349b9..1443b93 100644 --- a/app/views/admin/payments/source_views/_paypalexpress.html.erb +++ b/app/views/admin/payments/source_views/_paypalexpress.html.erb @@ -32,44 +32,79 @@ <%= t('transactions') %> <% payment.log_entries.reverse.each do |log| %> - <% details = YAML.load(log.details) %> + <% details = YAML.load(log.details) rescue "" %> - - - - - - - - - - - - - - - - - - <% if details.params["payment_status"] == "Pending" %> + + <% if details.is_a? ActiveMerchant::Billing::PaypalExpressResponse %> - - + + + + + + + + + + + + + + + <% if details.params["payment_status"] == "Pending" %> + + + + + <% end %> + <% elsif details.is_a? ActiveMerchant::Billing::Integrations::Paypal::Notification %> + + + + + + + + + + + + + + + + <% else %> + + + + + + <% end %>
<%= t('transaction') %> <%= details.params["transaction_id"] %> - <%= log.created_at.to_s(:date_time24) %>
- <%= details.params["transaction_type"] %> - - <%= details.message %> - - <%= number_to_currency details.params["gross_amount"] %> -
- <%= details.params["message"] %> - - <%= details.params["payment_status"] %> -
- <%= details.params["pending_reason"] %> + <%= t('transaction') %> <%= details.params["transaction_id"] %> - <%= log.created_at.to_s(:date_time24) %>
+ <%= details.params["transaction_type"] %> + + <%= details.message %> + + <%= number_to_currency details.params["gross_amount"] %>
+ <%= details.params["message"] %> + + <%= details.params["payment_status"] %> +
+ <%= details.params["pending_reason"] %> +
<%= t('ipn_transaction') %> <%= details.params["txn_id"] %> - <%= log.created_at.to_s(:date_time24) %>
+ <%= details.params["txn_type"] %> + + <%= details.params["payment_status"] %> + + <%= number_to_currency details.params["mc_gross"] %> +
+ <%= details.params["payment_status"] %> +
<%= t('unknown_transaction') %> - <%= log.created_at.to_s(:date_time24) %>
<%= log.details %>
+
<% end %> - \ No newline at end of file + diff --git a/config/locales/en.yml b/config/locales/en.yml index 921235a..1208f78 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index c92a53b..a30791e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,16 +11,15 @@ Rails.application.routes.draw do end match '/paypal_notify' => 'paypal_express_callbacks#notify', :via => [:get, :post] - - resources :paypal_express_callbacks + namespace :admin do resources :orders do resources :paypal_payments do member do get :refund get :capture - end + end end end end -end \ No newline at end of file +end diff --git a/db/migrate/20100122120551_create_paypal_txns.rb b/db/migrate/20100122120551_create_paypal_txns.rb deleted file mode 100644 index 3f6c00c..0000000 --- a/db/migrate/20100122120551_create_paypal_txns.rb +++ /dev/null @@ -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 diff --git a/db/migrate/20100127083342_rename_avs_code.rb b/db/migrate/20100127083342_rename_avs_code.rb deleted file mode 100644 index 2b049c4..0000000 --- a/db/migrate/20100127083342_rename_avs_code.rb +++ /dev/null @@ -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 \ No newline at end of file diff --git a/db/migrate/20100128115525_add_transaction_id_to_ppx_txn.rb b/db/migrate/20100128115525_add_transaction_id_to_ppx_txn.rb deleted file mode 100644 index c14caa4..0000000 --- a/db/migrate/20100128115525_add_transaction_id_to_ppx_txn.rb +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb b/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb deleted file mode 100644 index 2d5069a..0000000 --- a/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb +++ /dev/null @@ -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. - # - # :pem 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 - # - # :signature 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 diff --git a/lib/active_merchant/billing/gateways/paypal_express.rb b/lib/active_merchant/billing/gateways/paypal_express.rb deleted file mode 100644 index 92bd770..0000000 --- a/lib/active_merchant/billing/gateways/paypal_express.rb +++ /dev/null @@ -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 diff --git a/lib/active_merchant/billing/gateways/paypal_express_uk.rb b/lib/active_merchant/billing/gateways/paypal_express_uk.rb deleted file mode 100644 index bc827d8..0000000 --- a/lib/active_merchant/billing/gateways/paypal_express_uk.rb +++ /dev/null @@ -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 - diff --git a/lib/spree/checkouts_controller_with_paypal_express.rb b/lib/spree/checkouts_controller_with_paypal_express.rb deleted file mode 100644 index a67d8e9..0000000 --- a/lib/spree/checkouts_controller_with_paypal_express.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Spree::CheckoutControllerWithPaypalExpress - def self.included(target) - target.before_filter :redirect_to_paypal_express_form, :only => [:update] - end - - private -end \ No newline at end of file diff --git a/lib/spree_paypal_express.rb b/lib/spree_paypal_express.rb index 707412c..21a36df 100644 --- a/lib/spree_paypal_express.rb +++ b/lib/spree_paypal_express.rb @@ -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 diff --git a/spec/controllers/checkout_controller_spec.rb b/spec/controllers/checkout_controller_spec.rb new file mode 100644 index 0000000..363c875 --- /dev/null +++ b/spec/controllers/checkout_controller_spec.rb @@ -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 + diff --git a/spec/factories/address_factory.rb b/spec/factories/address_factory.rb new file mode 100644 index 0000000..c796c7e --- /dev/null +++ b/spec/factories/address_factory.rb @@ -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 diff --git a/spec/factories/order_factory.rb b/spec/factories/order_factory.rb new file mode 100644 index 0000000..02ab1eb --- /dev/null +++ b/spec/factories/order_factory.rb @@ -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 diff --git a/spec/factories/state_factory.rb b/spec/factories/state_factory.rb new file mode 100644 index 0000000..1717afb --- /dev/null +++ b/spec/factories/state_factory.rb @@ -0,0 +1,5 @@ +Factory.define :md_state do |f| + f.name 'Maryland' + f.abbr 'MD' + f.country { |country| country.association(:country) } +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 353fdcc..1be8937 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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")