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 @@
+
+ <%= button_link_to t("resend"), resend_admin_order_url(@order), :method => :post, :icon => 'send-email' %>
+ <%= event_links %>
+
+
+<%= 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') %>
+
+
+
+ <%= t("event") %> |
+ <%= t("user") %> |
+ <%= "#{t('spree.date')}/#{t('spree.time')}" %> |
+
+ <% @order.state_events.sort.each do |event| %>
+
+ <%=t("#{event.name}") %> |
+ <%=event.user.email if event.user %> |
+ <%=event.created_at.to_s(:date_time24) %> |
+
+ <% end %>
+ <% if @order.state_events.empty? %>
+
+ <%= t("none_available") %> |
+
+ <% end %>
+
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"} %>
+
+
+
+ <%= "#{t('spree.date')}/#{t('spree.time')}" %> |
+ <%= t("amount") %> |
+ <%= t("type") %> |
+ |
+
+ <% @payments.each do |payment| %>
+
+ <%= 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 %>
+ |
+
+ <% end %>
+
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| %>
+
+
+ <%= t("transaction") %> |
+ <%= t("amount") %> |
+ <%= t("response_code") %> |
+ <%= "#{t('spree.date')}/#{t('spree.time')}" %> |
+
+ <% @paypal_payment.txns.each do |t| %>
+
+ <%=CreditcardTxn::TxnType.from_value t.txn_type.to_i%> |
+ <%=number_to_currency t.amount%> |
+ <%=t.response_code%> |
+ <%=t.created_at.to_s(:date_time24)%> |
+
+ <% end %>
+
+
+
+ <%= 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