diff --git a/README.markdown b/README.markdown index 062df30..1d66865 100644 --- a/README.markdown +++ b/README.markdown @@ -1,3 +1,75 @@ -= Paypal Express +# Paypal Express + +Bridge between ActiveMerchant's paypal express (PPX) gateway code and Spree + + +## Setup and Customization + +It's currently set up to run the UK version of the gateway, but this isn't an essential detail - should be easy to change. + + 1. Start by creating/identifying the relevant class representing your locale's paypal express gateway + and change the +clazz+ in the migration and/or the database. + + 2. Modify +lib/spree/paypal_express.rb+ to load up details for your gateway + +You'll notice that I'm using Spree's gateway config mechanism. This choice is debatable: Spree is basically +set up for using one gateway at a time, whereas we probably want a main gateway plus Paypal as a backup +choice. + + + +## Interaction with Spree + +The bridge code receives authorization and transaction info from PPX and converts it into the Spree +equivalent. + +The payment representation isn't perfect: basically, Spree is oriented towards creditcards and some +work is needed to generalise it to other options. For now, it is a bit hacked. (See the TODO list.) + + +## Relationship with active merchant + +This ext contains three files which are updates or extensions to current active merchant code. They are +loaded up when the extension is initialized, and will over-ride the existing gem files. The modifications +update the base protocol, eg allowing detailed order info to be passed, and supporting some of the new +options in version 57.0. + +## Testing + +Get an account for Paypal's Sandbox system first. Very good testing system! +Pity it logs you off automatically after a relatively short time period + + +## Status and Known issues + +IMPORTANT: requires edge rails (it might work with 0.8.4) + +[06Jul09] I don't know of any serious bugs or issues at present in this code, so you should be able to +start using this without serious problems - but do note the TODO list below. + +** Temporarily, I've had to over-ride two admin views: order/show and payments/index: this will be unpicked +once Spree is generalised to support payment types other than creditcards + +WARNING: there seems to be an issue with the :shipping_discount issue which causes submitted order +info to be ignored (and not displayed) - see +lib/spree/paypal_express.rb+ for more info, so I suggest +avoiding this option unless you've tested it. + + + +## TODO + + 0. Allow easy change of locale for gateway version + + 1. Move gateway config to the preferences system, to avoid interference with main gateways? + + 2. Add support for accepting PPX payment at the credit card stage (important) + + 3. Look at using PPX to assist in shipping method choices (or present user with a choice before + they jump to PPX interaction) + + 4. Improve payment tracking support in Spree (eg generalise beyond creditcard bias) + + 5. Add some tests + + 6. Get some of my code into active merchant -Description goes here \ No newline at end of file diff --git a/app/controllers/admin/paypal_payments_controller.rb b/app/controllers/admin/paypal_payments_controller.rb new file mode 100644 index 0000000..1adac44 --- /dev/null +++ b/app/controllers/admin/paypal_payments_controller.rb @@ -0,0 +1,52 @@ +class Admin::PaypalPaymentsController < Admin::BaseController + before_filter :load_data + before_filter :load_amount, :except => :country_changed + resource_controller + belongs_to :order + ssl_required + + update do + wants.html { redirect_to edit_object_url } + end + + def country_changed + end + + include Spree::PaypalExpress::Gateway + + def capture + if !@order.paypal_payments.empty? && (payment = @order.paypal_payments.last).can_capture? + + do_capture(payment.find_authorization) + + flash[:notice] = t("paypal_capture_complete") + else + flash[:error] = t("unable_to_capture_paypal") + end + redirect_to edit_object_url + end + + private + def load_data + load_object + @selected_country_id = params[:payment_presenter][:address_country_id].to_i if params.has_key?('payment_presenter') + @selected_country_id ||= @order.bill_address.country_id if @order and @order.bill_address + @selected_country_id ||= Spree::Config[:default_country_id] + + @states = State.find_all_by_country_id(@selected_country_id, :order => 'name') + @countries = Country.find(:all) + end + + # what for? + def load_amount + @amount = params[:amount] || @order.total + end + + def build_object + @object ||= end_of_association_chain.send parent? ? :build : :new, object_params + # not relevant? + # @object.creditcard = Creditcard.new(:address => @object.order.bill_address.clone) unless @object.creditcard + @object + end + +end diff --git a/app/views/admin/orders/show.html.erb b/app/views/admin/orders/show.html.erb new file mode 100644 index 0000000..338637f --- /dev/null +++ b/app/views/admin/orders/show.html.erb @@ -0,0 +1,71 @@ + + +<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Order Details"} %> + +<%= render :partial => 'admin/shared/order_details', :locals => {:order => @order} -%> + +<% unless @order.payments.empty? %> +
+ <%# look at the most recent (= up to date) payment %> + <% payment = @order.payments.last %> + <% if payment.class == "CreditcardPayment" %> +

<%= link_to t("bill_address"), edit_admin_order_creditcard_payment_url(@order, payment) %>

+ <%= render :partial => 'admin/shared/address', :locals => {:address => @order.bill_address} %> + <% else %> + <% url = edit_admin_order_paypal_payment_url(@order) %> <%#, payment) %> +

<%= link_to t("edit_paypal_info"), edit_admin_order_paypal_payment_url(@order, payment) %>

+ <% end %> +
+<% end %> +<% if @order.ship_address %> +
+

<%= link_to t("ship_address"), edit_admin_order_shipment_url(@order, @order.shipments.last) %>

+ <%= render :partial => 'admin/shared/address', :locals => {:address => @order.ship_address} %> +
+ <% end %> +
+ + + + + + + + +
<%= t("email") %>
<%= @order.email %>
+ +<% unless @order.special_instructions.blank? %> + + + + + + + +
<%= t("shipping_instructions") %>
<%= @order.special_instructions %>
+<% end %> + +

<%= t('history') %>

+ + + + + + + + <% @order.state_events.sort.each do |event| %> + + + + + + <% end %> + <% if @order.state_events.empty? %> + + + + <% end %> +
<%= t("event") %><%= t("user") %><%= "#{t('spree.date')}/#{t('spree.time')}" %>
<%=t("#{event.name}") %><%=event.user.email if event.user %><%=event.created_at.to_s(:date_time24) %>
<%= t("none_available") %>
diff --git a/app/views/admin/payments/index.html.erb b/app/views/admin/payments/index.html.erb new file mode 100644 index 0000000..51fc685 --- /dev/null +++ b/app/views/admin/payments/index.html.erb @@ -0,0 +1,31 @@ +
+ +
+
+ +<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %> + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + <% end %> +
<%= "#{t('spree.date')}/#{t('spree.time')}" %><%= t("amount") %><%= t("type") %>
<%= payment.created_at.to_s(:date_time24) %><%= number_to_currency(payment.amount) %><%= payment.class.to_s %> + <% url = payment.type == "CreditcardPayment" ? edit_admin_order_creditcard_payment_url(@order, payment) : edit_admin_order_paypal_payment_url(@order, payment) %> + <%= link_to_with_icon 'edit', t('edit'), url %> +
diff --git a/app/views/admin/paypal_payments/edit.html.erb b/app/views/admin/paypal_payments/edit.html.erb new file mode 100644 index 0000000..72d73cd --- /dev/null +++ b/app/views/admin/paypal_payments/edit.html.erb @@ -0,0 +1,33 @@ +<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %> +
+

<%= t("paypal_payment")%>

+
+<%= t("paypal_txn_id")%>:   #<%= @paypal_payment.creditcard.display_number %>
+
+<%=error_messages_for :paypal_payment %> + +<% form_for(@paypal_payment, :url => object_url, :html => { :method => :put}) do |payment_form| %> + + + + + + + + <% @paypal_payment.txns.each do |t| %> + + + + + + + <% end %> +
<%= t("transaction") %><%= t("amount") %><%= t("response_code") %><%= "#{t('spree.date')}/#{t('spree.time')}" %>
<%=CreditcardTxn::TxnType.from_value t.txn_type.to_i%><%=number_to_currency t.amount%><%=t.response_code%><%=t.created_at.to_s(:date_time24)%>
+ +

+ <%= button t('update') %> +

+ +<% end %> +<%= link_to t("capture").titleize, capture_admin_order_paypal_payment_url(@order, @paypal_payment), :confirm => t('are_you_sure_you_want_to_capture') if @paypal_payment.can_capture? %>   +<%= link_to t("list"), collection_url %> diff --git a/app/views/admin/paypal_payments/new.html.erb b/app/views/admin/paypal_payments/new.html.erb new file mode 100644 index 0000000..cdd55e5 --- /dev/null +++ b/app/views/admin/paypal_payments/new.html.erb @@ -0,0 +1,15 @@ +<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %> + +

<%= t("new_credit_card_payment")%>

+ +<%=error_messages_for :creditcard_payment %> +<% form_for @creditcard_payment, :url => collection_url do |payment_form| %> +

<%= t("billing_address")%>

+ <% payment_form.fields_for :creditcard do |creditcard_form| %> + <%= render :partial => 'admin/shared/form_address', :locals => {:f => creditcard_form} %> + <% end %> +

+ <%= button t('continue') %> + <%= t("or") %> <%= link_to t("actions.cancel"), admin_order_payments_url(@order) %> +

+<% end %> diff --git a/capture-notes b/capture-notes new file mode 100644 index 0000000..64defe3 --- /dev/null +++ b/capture-notes @@ -0,0 +1,28 @@ + +Results from a capture - how much do I need to keep? check... + +--- +:tax_amount: "37.50" +:gross_amount: "249.00" +:payment_status: Pending +:gross_amount_currency_id: GBP +:authorization_id: 6WF137128R766191U +:pending_reason: payment-review +:receipt_id: +:transaction_id: 45D337153N9936001 +:build: "962735" +:fee_amount: "8.67" +:reason_code: none +:correlation_id: cee300cb3a234 +:fee_amount_currency_id: GBP +:transaction_type: express-checkout +:ack: Success +:timestamp: "2009-07-06T10:36:35Z" +:protection_eligibility: Ineligible +:parent_transaction_id: 6WF137128R766191U +:tax_amount_currency_id: GBP +:version: "57.0" +:payment_type: instant +:exchange_rate: +:payment_date: "2009-07-06T10:36:34Z" + diff --git a/config/locales/en-GB.yml b/config/locales/en-GB.yml new file mode 100644 index 0000000..027403c --- /dev/null +++ b/config/locales/en-GB.yml @@ -0,0 +1,7 @@ +--- +en-GB: + paypal_payment: Paypal Express Payment + paypal_txn_id: Transaction Code + paypal_capture_complete: Paypal Transaction has been captured. + unable_to_capture_paypal: Unable to capture Paypal Transaction. + diff --git a/config/routes.rb b/config/routes.rb index 11a0fd4..caf8bc8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,10 @@ # Put your extension routes here. -map.resources :orders, :member => {:paypal_checkout => :any, :paypal_finish => :any} +map.resources :orders, :member => {:paypal_checkout => :any, :paypal_finish => :any} + +map.namespace :admin do |admin| + admin.resources :orders do |order| + order.resources :paypal_payments, :member => {:capture => :get}, :has_many => [:paypal_payments] + end +end diff --git a/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb b/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb new file mode 100644 index 0000000..0bab237 --- /dev/null +++ b/lib/active_merchant/billing/gateways/paypal/paypal_common_api.rb @@ -0,0 +1,385 @@ +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 + + API_VERSION = '57.0' # TODO - check absolute adherence in this file, override in sub? + + URLS = { + :test => { :certificate => 'https://api.sandbox.paypal.com/2.0/', + :signature => 'https://api-3t.sandbox.paypal.com/2.0/' }, + :live => { :certificate => 'https://api-aa.paypal.com/2.0/', + :signature => 'https://api-3t.paypal.com/2.0/' } + } + + PAYPAL_NAMESPACE = 'urn:ebay:api:PayPalAPI' + EBAY_NAMESPACE = 'urn:ebay:apis:eBLBaseComponents' + + ENVELOPE_NAMESPACES = { 'xmlns:xsd' => 'http://www.w3.org/2001/XMLSchema', + 'xmlns:env' => 'http://schemas.xmlsoap.org/soap/envelope/', + 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance' + } + CREDENTIALS_NAMESPACES = { 'xmlns' => PAYPAL_NAMESPACE, + 'xmlns:n1' => EBAY_NAMESPACE, + 'env:mustUnderstand' => '0' + } + + AUSTRALIAN_STATES = { + 'ACT' => 'Australian Capital Territory', + 'NSW' => 'New South Wales', + 'NT' => 'Northern Territory', + 'QLD' => 'Queensland', + 'SA' => 'South Australia', + 'TAS' => 'Tasmania', + 'VIC' => 'Victoria', + 'WA' => 'Western Australia' + } + + SUCCESS_CODES = [ 'Success', 'SuccessWithWarning' ] + + FRAUD_REVIEW_CODE = "11610" + + # 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) + currency_code = options[:currency] || currency(item[:amount]) + xml.tag! 'n2:PaymentDetailItem' 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? + xml.tag! 'n2:Amount', amount(item[:amount]), 'currencyID' => currency_code unless item[:amount].blank? + 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) + # COULD USE options[:currency] || currency(options[:actual_opt]) + + 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' + 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 } if options[:items] + 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))) + + File.open("/tmp/paypal", "a") do |f| + f.puts "\n\n\n ************** #{Time.now}\n" + f.puts endpoint_url.inspect + f.puts "\n\n" + f.puts request.to_yaml + f.puts "\n\n" + f.puts response.to_yaml + f.puts "\n\n" + end + + 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 new file mode 100644 index 0000000..48491b4 --- /dev/null +++ b/lib/active_merchant/billing/gateways/paypal_express.rb @@ -0,0 +1,129 @@ +#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] + 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? + # flat rate shipping options -- required if using callback, TODO + xml.tag! 'n2:ReqConfirmShipping', options[:req_confirm_shipping] ? '1' : '0' + xml.tag! 'n2:NoShipping', options[:no_shipping] ? '1' : '0' + # NOT INCLUDED IN SETUP -- GRAB ELSEWHERE? -- xml.tag! 'n2:IPAddress', options[:ip] + 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 new file mode 100644 index 0000000..bc827d8 --- /dev/null +++ b/lib/active_merchant/billing/gateways/paypal_express_uk.rb @@ -0,0 +1,14 @@ +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/paypal_express.rb b/lib/spree/paypal_express.rb index ab5adcf..2347b85 100644 --- a/lib/spree/paypal_express.rb +++ b/lib/spree/paypal_express.rb @@ -1,12 +1,14 @@ -# Adapted for protx3ds +# WARNING: the details of UK tax and my site's shipping are a bit hard-coded here for now +# aim to unpick this later module Spree::PaypalExpress include ERB::Util include Spree::PaymentGateway + include Spree::PaypalExpress::Gateway def fixed_opts - { :description => "Parasols or related outdoor items", # site details... + { :description => "Goods from a Spree-based site", # site details... - #:page_style => "foobar", # merchant account can set default + #:page_style => "foobar", # merchant account can set named config :header_image => "https://" + Spree::Config[:site_url] + "/images/logo.png", :background_color => "e1e1e1", # must be hex only, six chars :header_background_color => "ffffff", @@ -14,12 +16,14 @@ module Spree::PaypalExpress :allow_note => true, :locale => Spree::Config[:default_locale], - :notify_url => 'to be done', + :notify_url => 'to be done', # this is a callback :req_confirm_shipping => false, # for security, might make an option later } end + # TODO: generalise the tax and shipping calcs + # might be able to get paypal to do some of the shipping choice and costing def order_opts(order) items = order.line_items.map do |item| { :name => item.variant.product.name, @@ -34,20 +38,16 @@ module Spree::PaypalExpress :depth => item.variant.weight } end - site = "localhost:3000" - site = Spree::Config[:site_url] - - opts = { :return_url => "https://" + site + "/orders/#{order.number}/paypal_finish", - :cancel_return_url => "http://" + site + "/orders/#{order.number}/edit", + opts = { :return_url => request.protocol + request.host_with_port + "/orders/#{order.number}/paypal_finish", + :cancel_return_url => "http://" + request.host_with_port + "/orders/#{order.number}/edit", :order_id => order.number, - :custom => order.number + '--' + order.number, + :custom => 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 @@ -55,6 +55,16 @@ module Spree::PaypalExpress # 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 } + + opts[:email] = current_user.email if current_user + + opts + end + + def all_opts(order) + shipping_cost = NetstoresShipping::Calculator.calculate_order_shipping(order) + opts = fixed_opts.merge(:shipping => shipping_cost).merge(order_opts order) + # 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 @@ -64,23 +74,20 @@ module Spree::PaypalExpress [: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) + response = gateway.setup_authorization(opts[:money], opts) - redirect_to (gateway.redirect_url_for out2.token) + gateway_error(response) unless response.success? + + redirect_to (gateway.redirect_url_for response.token) end def paypal_finish @@ -90,19 +97,15 @@ module Spree::PaypalExpress info = gateway.details_for params[:token] response = gateway.authorize(opts[:money], opts) - # 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 - # OFF FOR TESTING : gateway_error(response) - end + gateway_error(response) unless response.success? # now save info order = Order.find_by_number(params[:id]) - order.email = info.email - order.special_instructions = info.params["note"] + order.checkout.email = info.email + order.checkout.special_instructions = info.params["note"] ship_address = info.address - order.ship_address = Address.create :firstname => info.params["first_name"], + order_ship_address = Address.create :firstname => info.params["first_name"], :lastname => info.params["last_name"], :address1 => ship_address["address1"], :address2 => ship_address["address2"], @@ -111,11 +114,11 @@ module Spree::PaypalExpress :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, - :shipping_method => ShippingMethod.first # TODO: refine/choose - order.shipments << shipment - fake_card = Creditcard.new :order => order, + order.checkout.update_attributes :ship_address => order_ship_address, + :shipping_method => ShippingMethod.first # TODO: refine/choose + + fake_card = Creditcard.new :checkout => order.checkout, :cc_type => "visa", # hands are tied :month => Time.now.month, :year => Time.now.year, @@ -138,6 +141,22 @@ module Spree::PaypalExpress redirect_to order_url(order, :checkout_complete => true, :order_token => session[:order_token]) end + + def do_capture(authorization) + response = paypal_gateway.capture((100 * authorization.amount).to_i, authorization.response_code) + + gateway_error(response) unless response.success? + + # TODO needs to be cleaned up or recast... + payment = PaypalPayment.find(authorization.creditcard_payment_id) + + # create a transaction to reflect the capture + payment.txns << CreditcardTxn.new( :amount => authorization.amount, + :response_code => response.authorization, + :txn_type => CreditcardTxn::TxnType::CAPTURE ) + end + + private # copied from main spree code, and slightly tweaked diff --git a/paypal_express_extension.rb b/paypal_express_extension.rb index 6c40f40..47ba90a 100644 --- a/paypal_express_extension.rb +++ b/paypal_express_extension.rb @@ -14,9 +14,21 @@ class PaypalExpressExtension < Spree::Extension def activate # admin.tabs.add "Paypal Express", "/admin/paypal_express", :after => "Layouts", :visibility => [:all] + + # Load up over-rides for ActiveMerchant files + # these will be submitted to ActiveMerchant some time... + require File.join(PaypalExpressExtension.root, "lib", "active_merchant", "billing", "gateways", "paypal", "paypal_common_api.rb") + require File.join(PaypalExpressExtension.root, "lib", "active_merchant", "billing", "gateways", "paypal_express_uk.rb") + require File.join(PaypalExpressExtension.root, "lib", "active_merchant", "billing", "gateways", "paypal_express_uk.rb") + + + # inject paypal code into orders controller OrdersController.class_eval do ssl_required :paypal_checkout, :paypal_finish + include Spree::PaypalExpress end + + # probably not needed once the payments mech is generalised Order.class_eval do has_many :paypal_payments end