Significant changes: add missing AM libs, adapt to spree 0.8.4+,

fix admin interface

1. My modifications of AM files are now included and over-ride the
   installed lib
2. The admin interface now works, including being able to review
   paypal payments and do captures
3. The code works cleanly with the new checkout representation.
This commit is contained in:
paulcc 2009-07-06 11:57:58 +01:00
parent 766c79c77a
commit ed9771041c
14 changed files with 907 additions and 33 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,71 @@
<div class='toolbar order-links'>
<%= button_link_to t("resend"), resend_admin_order_url(@order), :method => :post, :icon => 'send-email' %>
<%= event_links %>
</div>
<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Order Details"} %>
<%= render :partial => 'admin/shared/order_details', :locals => {:order => @order} -%>
<% unless @order.payments.empty? %>
<div class='adr'>
<%# look at the most recent (= up to date) payment %>
<% payment = @order.payments.last %>
<% if payment.class == "CreditcardPayment" %>
<h4><%= link_to t("bill_address"), edit_admin_order_creditcard_payment_url(@order, payment) %></h4>
<%= render :partial => 'admin/shared/address', :locals => {:address => @order.bill_address} %>
<% else %>
<% url = edit_admin_order_paypal_payment_url(@order) %> <%#, payment) %>
<h4><%= link_to t("edit_paypal_info"), edit_admin_order_paypal_payment_url(@order, payment) %></h4>
<% end %>
</div>
<% end %>
<% if @order.ship_address %>
<div class='adr'>
<h4><%= link_to t("ship_address"), edit_admin_order_shipment_url(@order, @order.shipments.last) %></h4>
<%= render :partial => 'admin/shared/address', :locals => {:address => @order.ship_address} %>
</div>
<% end %>
<hr />
<table class="index">
<tr>
<th><%= t("email") %></th>
</tr>
<tr>
<td><%= @order.email %></td>
</tr>
</table>
<% unless @order.special_instructions.blank? %>
<table class="index">
<tr>
<th><%= t("shipping_instructions") %></th>
</tr>
<tr>
<td><pre><%= @order.special_instructions %></pre></td>
</tr>
</table>
<% end %>
<h4><%= t('history') %></h4>
<table class="index">
<tr>
<th><%= t("event") %></th>
<th><%= t("user") %></th>
<th><%= "#{t('spree.date')}/#{t('spree.time')}" %></th>
</tr>
<% @order.state_events.sort.each do |event| %>
<tr>
<td><%=t("#{event.name}") %></td>
<td><%=event.user.email if event.user %></td>
<td><%=event.created_at.to_s(:date_time24) %></td>
</tr>
<% end %>
<% if @order.state_events.empty? %>
<tr>
<td colspan="3"><%= t("none_available") %></td>
</tr>
<% end %>
</table>

View File

@ -0,0 +1,31 @@
<div class='toolbar'>
<ul class='actions'>
<li>
<%= button_link_to t("new_credit_card_payment"), new_admin_order_creditcard_payment_url(@order), :icon => 'add' %>
</li>
</ul>
<br class='clear' />
</div>
<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %>
<table class="index">
<tr>
<th><%= "#{t('spree.date')}/#{t('spree.time')}" %></th>
<th><%= t("amount") %></th>
<th><%= t("type") %></th>
<th></th>
</tr>
<% @payments.each do |payment| %>
<tr>
<td><%= payment.created_at.to_s(:date_time24) %></td>
<td><%= number_to_currency(payment.amount) %></td>
<td><%= payment.class.to_s %></td>
<!-- TODO: don't assume credit card, make it possible to edit other kinds of payments -->
<td>
<% 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 %>
</td>
</tr>
<% end %>
</table>

View File

@ -0,0 +1,33 @@
<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %>
<br/>
<h2><%= t("paypal_payment")%></h2>
<br/>
<b><%= t("paypal_txn_id")%>: </b> &nbsp; #<%= @paypal_payment.creditcard.display_number %><br/>
<br/>
<%=error_messages_for :paypal_payment %>
<% form_for(@paypal_payment, :url => object_url, :html => { :method => :put}) do |payment_form| %>
<table class="index">
<tr>
<th><%= t("transaction") %></th>
<th><%= t("amount") %></th>
<th><%= t("response_code") %></th>
<th><%= "#{t('spree.date')}/#{t('spree.time')}" %></th>
</tr>
<% @paypal_payment.txns.each do |t| %>
<tr>
<td><%=CreditcardTxn::TxnType.from_value t.txn_type.to_i%></td>
<td><%=number_to_currency t.amount%></td>
<td><%=t.response_code%></td>
<td><%=t.created_at.to_s(:date_time24)%></td>
</tr>
<% end %>
</table>
<p class="form-buttons">
<%= button t('update') %>
</p>
<% 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? %> &nbsp;
<%= link_to t("list"), collection_url %>

View File

@ -0,0 +1,15 @@
<%= render :partial => 'admin/shared/order_tabs', :locals => {:current => "Payments"} %>
<h2><%= t("new_credit_card_payment")%></h2>
<%=error_messages_for :creditcard_payment %>
<% form_for @creditcard_payment, :url => collection_url do |payment_form| %>
<h4><%= t("billing_address")%></h4>
<% payment_form.fields_for :creditcard do |creditcard_form| %>
<%= render :partial => 'admin/shared/form_address', :locals => {:f => creditcard_form} %>
<% end %>
<p class="form-buttons">
<%= button t('continue') %>
<%= t("or") %> <%= link_to t("actions.cancel"), admin_order_payments_url(@order) %>
</p>
<% end %>

28
capture-notes Normal file
View File

@ -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"

7
config/locales/en-GB.yml Normal file
View File

@ -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.

View File

@ -2,3 +2,9 @@
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

View File

@ -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.
#
# <tt>:pem</tt> The text of your PayPal PEM file. Note
# this is not the path to file, but its
# contents. If you are only using one PEM
# file on your site you can declare it
# globally and then you won't need to
# include this option
#
# <tt>:signature</tt> The text of your PayPal signature.
# If you are only using one API Signature
# on your site you can declare it
# globally and then you won't need to
# include this option
def initialize(options = {})
requires!(options, :login, :password)
@options = {
:pem => pem_file,
:signature => signature
}.update(options)
if @options[:pem].blank? && @options[:signature].blank?
raise ArgumentError, "An API Certificate or API Signature is required to make requests to PayPal"
end
super
end
def test?
@options[:test] || Base.gateway_mode == :test
end
def reauthorize(money, authorization, options = {})
commit 'DoReauthorization', build_reauthorize_request(money, authorization, options)
end
def capture(money, authorization, options = {})
commit 'DoCapture', build_capture_request(money, authorization, options)
end
# Transfer money to one or more recipients.
#
# gateway.transfer 1000, 'bob@example.com',
# :subject => "The money I owe you", :note => "Sorry it's so late"
#
# gateway.transfer [1000, 'fred@example.com'],
# [2450, 'wilma@example.com', :note => 'You will receive another payment on 3/24'],
# [2000, 'barney@example.com'],
# :subject => "Your Earnings", :note => "Thanks for your business."
#
def transfer(*args)
commit 'MassPay', build_mass_pay_request(*args)
end
def void(authorization, options = {})
commit 'DoVoid', build_void_request(authorization, options)
end
def credit(money, identification, options = {})
commit 'RefundTransaction', build_credit_request(money, identification, options)
end
private
def build_reauthorize_request(money, authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoReauthorizationReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoReauthorizationRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
end
end
xml.target!
end
def build_capture_request(money, authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoCaptureReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoCaptureRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'CompleteType', 'Complete'
xml.tag! 'Note', options[:description]
end
end
xml.target!
end
def build_credit_request(money, identification, options)
xml = Builder::XmlMarkup.new
xml.tag! 'RefundTransactionReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'RefundTransactionRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'TransactionID', identification
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'RefundType', 'Partial'
xml.tag! 'Memo', options[:note] unless options[:note].blank?
end
end
xml.target!
end
def build_void_request(authorization, options)
xml = Builder::XmlMarkup.new
xml.tag! 'DoVoidReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'DoVoidRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'AuthorizationID', authorization
xml.tag! 'Note', options[:description]
end
end
xml.target!
end
def build_mass_pay_request(*args)
default_options = args.last.is_a?(Hash) ? args.pop : {}
recipients = args.first.is_a?(Array) ? args : [args]
xml = Builder::XmlMarkup.new
xml.tag! 'MassPayReq', 'xmlns' => PAYPAL_NAMESPACE do
xml.tag! 'MassPayRequest', 'xmlns:n2' => EBAY_NAMESPACE do
xml.tag! 'n2:Version', API_VERSION
xml.tag! 'EmailSubject', default_options[:subject] if default_options[:subject]
recipients.each do |money, recipient, options|
options ||= default_options
xml.tag! 'MassPayItem' do
xml.tag! 'ReceiverEmail', recipient
xml.tag! 'Amount', amount(money), 'currencyID' => options[:currency] || currency(money)
xml.tag! 'Note', options[:note] if options[:note]
xml.tag! 'UniqueId', options[:unique_id] if options[:unique_id]
end
end
end
end
xml.target!
end
def parse(action, xml)
response = {}
error_messages = []
error_codes = []
xml = REXML::Document.new(xml)
if root = REXML::XPath.first(xml, "//#{action}Response")
root.elements.each do |node|
case node.name
when 'Errors'
short_message = nil
long_message = nil
node.elements.each do |child|
case child.name
when "LongMessage"
long_message = child.text unless child.text.blank?
when "ShortMessage"
short_message = child.text unless child.text.blank?
when "ErrorCode"
error_codes << child.text unless child.text.blank?
end
end
if message = long_message || short_message
error_messages << message
end
else
parse_element(response, node)
end
end
response[:message] = error_messages.uniq.join(". ") unless error_messages.empty?
response[:error_codes] = error_codes.uniq.join(",") unless error_codes.empty?
elsif root = REXML::XPath.first(xml, "//SOAP-ENV:Fault")
parse_element(response, root)
response[:message] = "#{response[:faultcode]}: #{response[:faultstring]} - #{response[:detail]}"
end
response
end
def parse_element(response, node)
if node.has_elements?
node.elements.each{|e| parse_element(response, e) }
else
response[node.name.underscore.to_sym] = node.text
node.attributes.each do |k, v|
response["#{node.name.underscore}_#{k.underscore}".to_sym] = v if k == 'currencyID'
end
end
end
def build_request(body)
xml = Builder::XmlMarkup.new
xml.instruct!
xml.tag! 'env:Envelope', ENVELOPE_NAMESPACES do
xml.tag! 'env:Header' do
add_credentials(xml)
end
xml.tag! 'env:Body' do
xml << body
end
end
xml.target!
end
def add_credentials(xml)
xml.tag! 'RequesterCredentials', CREDENTIALS_NAMESPACES do
xml.tag! 'n1:Credentials' do
xml.tag! 'Username', @options[:login]
xml.tag! 'Password', @options[:password]
xml.tag! 'Subject', @options[:subject]
xml.tag! 'Signature', @options[:signature] unless @options[:signature].blank?
end
end
end
def add_address(xml, element, address)
return if address.nil?
xml.tag! element do
xml.tag! 'n2:Name', address[:name]
xml.tag! 'n2:Street1', address[:address1]
xml.tag! 'n2:Street2', address[:address2]
xml.tag! 'n2:CityName', address[:city]
xml.tag! 'n2:StateOrProvince', address[:state].blank? ? 'N/A' : address[:state]
xml.tag! 'n2:Country', address[:country]
xml.tag! 'n2:PostalCode', address[:zip]
xml.tag! 'n2:Phone', address[:phone]
end
end
def add_payment_detail_item(xml, item)
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

View File

@ -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

View File

@ -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

View File

@ -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 => "<pre>" + response.params.inspect + "\n\n\n" + params.to_yaml + "\n\n\n" + response.to_yaml + "\n\n\n" + info.to_yaml + "</pre>" 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

View File

@ -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