From 11f88be5c57eda80b6ee6fd5ff3060d3b8f71925 Mon Sep 17 00:00:00 2001 From: paulcc Date: Thu, 14 May 2009 11:11:59 +0100 Subject: [PATCH] Basic support for Paypal Express in Spree This commit contains enough to do an express checkout authorization (not purchase) from the cart page, to save all relevant info, and to be able to view some of it in the admin pages. It is very much work in progress! Some admin features won't work (eg captures, viewing transactions), since handling of payments and gateways needs to be generalised first. Also need to get the orders handled like those for guest checkouts. It's also tied to GB/UK details at the moment (this will change), and it needs a modified version of active merchant which will be released/circulated soon. --- README.markdown | 3 + Rakefile | 120 +++++++++++++ app/models/paypal_payment.rb | 18 ++ app/views/shared/_paypal_button.html.erb | 5 + app/views/shared/_paypal_logo.html.erb | 2 + config/routes.rb | 4 + ...513103111_create_paypal_express_gateway.rb | 15 ++ lib/spree/paypal_express.rb | 163 ++++++++++++++++++ lib/tasks/paypal_express_extension_tasks.rake | 28 +++ paypal_express_extension.rb | 24 +++ public/images/paypal111.gif | Bin 0 -> 1383 bytes spec/spec.opts | 6 + spec/spec_helper.rb | 37 ++++ 13 files changed, 425 insertions(+) create mode 100644 README.markdown create mode 100644 Rakefile create mode 100644 app/models/paypal_payment.rb create mode 100644 app/views/shared/_paypal_button.html.erb create mode 100644 app/views/shared/_paypal_logo.html.erb create mode 100644 config/routes.rb create mode 100644 db/migrate/20090513103111_create_paypal_express_gateway.rb create mode 100644 lib/spree/paypal_express.rb create mode 100644 lib/tasks/paypal_express_extension_tasks.rake create mode 100644 paypal_express_extension.rb create mode 100644 public/images/paypal111.gif create mode 100644 spec/spec.opts create mode 100644 spec/spec_helper.rb diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..062df30 --- /dev/null +++ b/README.markdown @@ -0,0 +1,3 @@ += Paypal Express + +Description goes here \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2070887 --- /dev/null +++ b/Rakefile @@ -0,0 +1,120 @@ +# I think this is the one that should be moved to the extension Rakefile template + +# In rails 1.2, plugins aren't available in the path until they're loaded. +# Check to see if the rspec plugin is installed first and require +# it if it is. If not, use the gem version. + +# Determine where the RSpec plugin is by loading the boot +unless defined? SPREE_ROOT + ENV["RAILS_ENV"] = "test" + case + when ENV["SPREE_ENV_FILE"] + require File.dirname(ENV["SPREE_ENV_FILE"]) + "/boot" + when File.dirname(__FILE__) =~ %r{vendor/SPREE/vendor/extensions} + require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../../")}/config/boot" + else + require "#{File.expand_path(File.dirname(__FILE__) + "/../../../")}/config/boot" + end +end + +require 'rake' +require 'rake/rdoctask' +require 'rake/testtask' + +rspec_base = File.expand_path(SPREE_ROOT + '/vendor/plugins/rspec/lib') +$LOAD_PATH.unshift(rspec_base) if File.exist?(rspec_base) +require 'spec/rake/spectask' +# require 'spec/translator' + +# Cleanup the SPREE_ROOT constant so specs will load the environment +Object.send(:remove_const, :SPREE_ROOT) + +extension_root = File.expand_path(File.dirname(__FILE__)) + +task :default => :spec +task :stats => "spec:statsetup" + +desc "Run all specs in spec directory" +Spec::Rake::SpecTask.new(:spec) do |t| + t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""] + t.spec_files = FileList["#{extension_root}/spec/**/*_spec.rb"] +end + +namespace :spec do + desc "Run all specs in spec directory with RCov" + Spec::Rake::SpecTask.new(:rcov) do |t| + t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""] + t.spec_files = FileList['spec/**/*_spec.rb'] + t.rcov = true + t.rcov_opts = ['--exclude', 'spec', '--rails'] + end + + desc "Print Specdoc for all specs" + Spec::Rake::SpecTask.new(:doc) do |t| + t.spec_opts = ["--format", "specdoc", "--dry-run"] + t.spec_files = FileList['spec/**/*_spec.rb'] + end + + [:models, :controllers, :views, :helpers].each do |sub| + desc "Run the specs under spec/#{sub}" + Spec::Rake::SpecTask.new(sub) do |t| + t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""] + t.spec_files = FileList["spec/#{sub}/**/*_spec.rb"] + end + end + + # Hopefully no one has written their extensions in pre-0.9 style + # desc "Translate specs from pre-0.9 to 0.9 style" + # task :translate do + # translator = ::Spec::Translator.new + # dir = RAILS_ROOT + '/spec' + # translator.translate(dir, dir) + # end + + # Setup specs for stats + task :statsetup do + require 'code_statistics' + ::STATS_DIRECTORIES << %w(Model\ specs spec/models) + ::STATS_DIRECTORIES << %w(View\ specs spec/views) + ::STATS_DIRECTORIES << %w(Controller\ specs spec/controllers) + ::STATS_DIRECTORIES << %w(Helper\ specs spec/views) + ::CodeStatistics::TEST_TYPES << "Model specs" + ::CodeStatistics::TEST_TYPES << "View specs" + ::CodeStatistics::TEST_TYPES << "Controller specs" + ::CodeStatistics::TEST_TYPES << "Helper specs" + ::STATS_DIRECTORIES.delete_if {|a| a[0] =~ /test/} + end + + namespace :db do + namespace :fixtures do + desc "Load fixtures (from spec/fixtures) into the current environment's database. Load specific fixtures using FIXTURES=x,y" + task :load => :environment do + require 'active_record/fixtures' + ActiveRecord::Base.establish_connection(RAILS_ENV.to_sym) + (ENV['FIXTURES'] ? ENV['FIXTURES'].split(/,/) : Dir.glob(File.join(RAILS_ROOT, 'spec', 'fixtures', '*.{yml,csv}'))).each do |fixture_file| + Fixtures.create_fixtures('spec/fixtures', File.basename(fixture_file, '.*')) + end + end + end + end +end + +desc 'Generate documentation for the paypal_express extension.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'PaypalExpressExtension' + rdoc.options << '--line-numbers' << '--inline-source' + rdoc.rdoc_files.include('README') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +# For extensions that are in transition +desc 'Test the paypal_express extension.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +# Load any custom rakefiles for extension +Dir[File.dirname(__FILE__) + '/tasks/*.rake'].sort.each { |f| require f } \ No newline at end of file diff --git a/app/models/paypal_payment.rb b/app/models/paypal_payment.rb new file mode 100644 index 0000000..ac76f9f --- /dev/null +++ b/app/models/paypal_payment.rb @@ -0,0 +1,18 @@ +class PaypalPayment < Payment + has_many :creditcard_txns, :foreign_key => 'creditcard_payment_id' # reused and faked + belongs_to :creditcard # allow for saving of fake details + # accepts_nested_attributes_for :creditcard + + alias :txns :creditcard_txns # should PUSH to parent/interface + + def find_authorization + #find the transaction associated with the original authorization/capture + txns.find(:first, + :conditions => ["txn_type = ? AND response_code IS NOT NULL", CreditcardTxn::TxnType::AUTHORIZE], + :order => 'created_at DESC') + end + + def can_capture? # push to parent? perhaps not + txns.last == find_authorization + end +end diff --git a/app/views/shared/_paypal_button.html.erb b/app/views/shared/_paypal_button.html.erb new file mode 100644 index 0000000..8523dbf --- /dev/null +++ b/app/views/shared/_paypal_button.html.erb @@ -0,0 +1,5 @@ +
 
+ + + +OR 
CHOOSE
diff --git a/app/views/shared/_paypal_logo.html.erb b/app/views/shared/_paypal_logo.html.erb new file mode 100644 index 0000000..43e5c46 --- /dev/null +++ b/app/views/shared/_paypal_logo.html.erb @@ -0,0 +1,2 @@ +!-- PayPal Logo -->
Acceptance Mark
+ diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..11a0fd4 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,4 @@ +# Put your extension routes here. + +map.resources :orders, :member => {:paypal_checkout => :any, :paypal_finish => :any} + diff --git a/db/migrate/20090513103111_create_paypal_express_gateway.rb b/db/migrate/20090513103111_create_paypal_express_gateway.rb new file mode 100644 index 0000000..4854be6 --- /dev/null +++ b/db/migrate/20090513103111_create_paypal_express_gateway.rb @@ -0,0 +1,15 @@ +class CreatePaypalExpressGateway < ActiveRecord::Migration + def self.up + login = GatewayOption.create(:name => "login", :description => "Your login email.") + password = GatewayOption.create(:name => "password", :description => "Your Paypal API Credentials Password.") + signature = GatewayOption.create(:name => "signature", :textarea => true, :description => "Your Paypal API Credentials signature string.") + + gateway = Gateway.create(:name => "Paypal Express UK", + :clazz => "ActiveMerchant::Billing::PaypalExpressUkGateway", + :description => "Active Merchant's Paypal Express (UK) Gateway.", + :gateway_options => [login, password, signature]) + end + + def self.down + end +end diff --git a/lib/spree/paypal_express.rb b/lib/spree/paypal_express.rb new file mode 100644 index 0000000..f5953ec --- /dev/null +++ b/lib/spree/paypal_express.rb @@ -0,0 +1,163 @@ +# Adapted for protx3ds +module Spree::PaypalExpress + include ERB::Util + include Spree::PaymentGateway + + def fixed_opts + { :description => "Parasols or related outdoor items", # site details... + + #:page_style => "foobar", # merchant account can set default + :header_image => "https://" + Spree::Config[:site_url] + "/images/logo.png", + :background_color => "e1e1e1", # must be hex only, six chars + :header_background_color => "ffffff", + :header_border_color => "00735a", + + :allow_note => true, + :locale => Spree::Config[:default_locale], + :notify_url => 'to be done', + + :req_confirm_shipping => false, # for security, might make an option later + } + end + + def order_opts(order) + items = order.line_items.map do |item| + { :name => item.variant.product.name, + :description => item.variant.product.description[0..120], + :sku => item.variant.sku, + :qty => item.quantity, + :amount => item.price - 0.15 * item.price, # avoid some rounding err, more needed + :tax => 0.15 * item.price, + :weight => item.variant.weight, + :height => item.variant.height, + :width => item.variant.width, + :depth => item.variant.weight } + end + + site = Spree::Config[:site_url] + site = "localhost:3000" + + opts = { :return_url => "https://" + site + "/orders/#{order.number}/paypal_finish", + :cancel_return_url => "http://" + site + "/orders/#{order.number}/edit", + :order_id => order.number, + :custom => order.number + '--' + order.number, + + # :no_shipping => false, + # :address_override => false, + + :items => items, + :subtotal => items.map {|i| i[:amount] * i[:qty] }.sum, + :shipping => NetstoresShipping::Calculator.calculate_order_shipping(order), # NEED HIDE + :handling => 0, + :tax => items.map {|i| i[:tax] * i[:qty]}.sum + + # WARNING -- don't use :ship_discount, => :insurance_offered, :insurance since + # they've not been tested and may trigger some paypal bugs, eg not showing order + # see http://www.pdncommunity.com/t5/PayPal-Developer-Blog/Displaying-Order-Details-in-Express-Checkout/bc-p/92902#C851 + } + # WARNING: paypal expects this sum to work (TODO: shift to AM code? and throw wobbly?) + # however: might be rounding issues when it comes to tax, though you can capture slightly extra + opts[:money] = opts.slice(:subtotal, :shipping, :handling, :tax).values.sum + if opts[:money] != order.total + raise "Ouch - precision problems: #{opts[:money]} vs #{order.total}" + end + + [:money, :subtotal, :shipping, :handling, :tax].each {|amt| opts[amt] *= 100} + opts[:items].each {|item| [:amount,:tax].each {|amt| item[amt] *= 100} } + opts[:email] = current_user.email if current_user + + opts + end + + def all_opts(order) + fixed_opts.merge(order_opts order) + end + + def paypal_checkout + # need build etc? at least to finalise the total? + gateway = paypal_gateway + + opts = all_opts(@order) + out2 = gateway.setup_authorization(opts[:money], opts) + + redirect_to (gateway.redirect_url_for out2.token) + end + + def paypal_finish + gateway = paypal_gateway + opts = { :token => params[:token], + :payer_id => params[:PayerID] }.merge all_opts(@order) + info = gateway.details_for params[:token] + response = gateway.authorize(opts[:money], opts) + #render :text => "
" + params.to_yaml + "\n\n\n" + out1.to_yaml + "\n\n\n" + info.to_yaml + "
" + # unless gateway.successful? response + unless [ 'Success', 'SuccessWithWarning' ].include?(response.params["ack"]) ## HACKY + # TMP render :text => "
" + response.params.inspect + "\n\n\n" + params.to_yaml + "\n\n\n" + response.to_yaml + "\n\n\n" + info.to_yaml + "
" and return + # gateway_error(response) + end + + # now save info + order = Order.find_by_number(params[:id]) + order.email = info.email + order.special_instructions = info.params["note"] + + ship_address = info.address + order.ship_address = Address.create :firstname => info.params["first_name"], + :lastname => info.params["last_name"], + :address1 => ship_address["address1"], + :address2 => ship_address["address2"], + :city => ship_address["city"], + :state => State.find_by_name(ship_address["state"]), + :country => Country.find_by_iso(ship_address["country"]), + :zipcode => ship_address["zip"], + :phone => ship_address["phone"] || "(not given)" + shipment = Shipment.create :address => order.ship_address, + :ship_method => ShippingMethod.first # TODO: refine/choose + order.shipments << shipment + + fake_card = Creditcard.new :order => order, + :cc_type => "visa", # hands are tied + :month => Time.now.month, + :year => Time.now.year, + :first_name => info.params["first_name"], + :last_name => info.params["last_name"], + :display_number => "paypal:" + info.payer_id + payment = order.paypal_payments.create(:amount => response.params["gross_amount"].to_i || 999, + :creditcard => fake_card) + + # query - need 0 in amount for an auth? see main code + transaction = CreditcardTxn.new( :amount => response.params["gross_amount"].to_i || 999, + :response_code => response.authorization, + :txn_type => CreditcardTxn::TxnType::AUTHORIZE) + payment.creditcard_txns << transaction + + order.save! + + order.complete # get return of status? throw of problems??? else weak go-ahead + session[:order_id] = nil if order.checkout_complete + redirect_to order_url(order, :checkout_complete => true, :order_token => session[:order_token]) + end + + private + + # temporary until options are sorted out + def paypal_gateway + gw_opts = { :login => "paul_1240924284_biz_api1.rocket-works.co.uk", + :password => "HPDLRQ5DGPAWDAUB", + :signature => "A2VYNHC1wYRx0ZwMX6dXwoFDGTMnAYt4SmzCH6LS3nVKLszXCtL-rp9o" } # or by cls + gateway = ActiveMerchant::Billing::PaypalExpressUkGateway.new gw_opts + return gateway + + #? return Spree::BogusGateway.new if ENV['RAILS_ENV'] == "development" and Spree::Gateway::Config[:use_bogus] + gateway_config = GatewayConfiguration.find_by_name "Paypal Express (UK)" + config_options = {} + gateway_config.gateway_option_values.each do |option_value| + key = option_value.gateway_option.name.to_sym + config_options[key] = option_value.value + end + gateway = gateway_config.gateway.clazz.constantize.new(config_options) + # + # => [#] >> GatewayConfiguration => GatewayConfiguration(id: integer, gateway_id: integer, created_at: datetime, updated_at: datetime) >> GatewayConfiguration.all => [#] + end + +end diff --git a/lib/tasks/paypal_express_extension_tasks.rake b/lib/tasks/paypal_express_extension_tasks.rake new file mode 100644 index 0000000..28624e9 --- /dev/null +++ b/lib/tasks/paypal_express_extension_tasks.rake @@ -0,0 +1,28 @@ +namespace :db do + desc "Bootstrap your database for Spree." + task :bootstrap => :environment do + # load initial database fixtures (in db/sample/*.yml) into the current environment's database + ActiveRecord::Base.establish_connection(RAILS_ENV.to_sym) + Dir.glob(File.join(PaypalExpressExtension.root, "db", 'sample', '*.{yml,csv}')).each do |fixture_file| + Fixtures.create_fixtures("#{PaypalExpressExtension.root}/db/sample", File.basename(fixture_file, '.*')) + end + end +end + +namespace :spree do + namespace :extensions do + namespace :paypal_express do + desc "Copies public assets of the Paypal Express to the instance public/ directory." + task :update => :environment do + is_svn_git_or_dir = proc {|path| path =~ /\.svn/ || path =~ /\.git/ || File.directory?(path) } + Dir[PaypalExpressExtension.root + "/public/**/*"].reject(&is_svn_git_or_dir).each do |file| + path = file.sub(PaypalExpressExtension.root, '') + directory = File.dirname(path) + puts "Copying #{path}..." + mkdir_p RAILS_ROOT + directory + cp file, RAILS_ROOT + path + end + end + end + end +end \ No newline at end of file diff --git a/paypal_express_extension.rb b/paypal_express_extension.rb new file mode 100644 index 0000000..6c40f40 --- /dev/null +++ b/paypal_express_extension.rb @@ -0,0 +1,24 @@ +# Uncomment this if you reference any of your controllers in activate +# require_dependency 'application' + +class PaypalExpressExtension < Spree::Extension + version "1.0" + description "Describe your extension here" + url "http://yourwebsite.com/paypal_express" + + # Please use paypal_express/config/routes.rb instead for extension routes. + + # def self.require_gems(config) + # config.gem "gemname-goes-here", :version => '1.2.3' + # end + + def activate + # admin.tabs.add "Paypal Express", "/admin/paypal_express", :after => "Layouts", :visibility => [:all] + OrdersController.class_eval do + ssl_required :paypal_checkout, :paypal_finish + end + Order.class_eval do + has_many :paypal_payments + end + end +end diff --git a/public/images/paypal111.gif b/public/images/paypal111.gif new file mode 100644 index 0000000000000000000000000000000000000000..d022a4daabb082468048174620ec079d8d3ccae0 GIT binary patch literal 1383 zcmV-t1(^CrNk%w1VQ&B>0Q>*||Ns9098!Cpy)j*cwa?xVL37^Y@0_y6N^z89imd=J zV2smr4!`Aor|L5!P|NH;v@Avof_xJn%000000000000000 z000000000000000EC2ui0B-;#000I4U?7fUX`X1Ru59a`KrGL6ZQppV?|eUtzMybO zEdByFiE_!L7>&%R#Q6je7K?&*TR_BswFq`hUJMY4gQ40Jm&xb!nkY~x9C*Cl3jos+ zb_{}pf_VxM0&F{g4uph;h*Uucc4n4#iG4^k6a$9{prHr@0}Bug4hjejia4HKqM)Rv zsHze|0}cxo6uklr3It!9GX|%8HWCH`1;7cdHWjDONy`wa)IAf^GYAX|x0+!J44F9( zxe5^z5dsOK2?7xo5(oD8_%R0f_J9lrJ02`ZMSz_koNcyEt2G6eykl`E@ zKrR4py3!CZP{G242L~w0L?oc_$pfCu7A|~hz>ElVv-L!^K(B!YegO+M+f1%En{J84 zctFk^0Je7ugqwUZ2Z4wVKLJ!4ECv&-+l@77eh`z9e6-0;fXUtE(ItwnidNnkRbpGpi;m9 zrwAf~S_=WgfMmHiFo1^u8~}`rGrU$nh$EKxlP7cqpxQzL%2lFl!_3e?XERVh&jmGH z2vY#Sm=Vu!B)aGrfr5m$WRXqMBgsM@IY1=@Azmp-iKWR8LqNtjFoc#$)Jr8a!E9d#8kAfb&T zsgy(n%?Y@v4%^5iVFZ}aKv7z&RdCNi1C^yqcZa?+z)hqnP=Fob=_iy@*MK9`t?w*A z&UgpbLsWOL{@$T%3~Y_)M}l(M=*VCVx#aPqn-6TOocJ;JdF%fVY>@x`;h%GQ)_=MzoRg+jD)>^}K?P zG+2bC4Cm#P2p|er`fzM(#QhlEGuUl|rTTgX45u0z^Wp@MPr#O`Giav(mJ=lKYvgRg z=ZORGlzVeI0P-Oax#VpheifDmXl_8{qCXyaTKKkCqr79;p>Pcr$(X=9r!>V;C8v>t zz-l=`SM69Y0fpX21N@Fa@VOIQec51~^pPGlTL;r1GR0&^0+AL_9fC@!#P{|x$z22A zkH7>{nIPSrB#E(zVww$@nu6vT`4f>pOv9#Z=qG@A(n0??Vg>{{@I0Yu#RV)t4;<8C zUrpl