So that I don't forget how to do this in the future (I spent 3+ hours today smashing my head into my desk because good documentation on this is hard to find).
PayPal Express is a multi-step process.
- Craft an initial request to PayPal, indicating that you wish to send a customer to them to purchase an item. It includes various fields such as a list of items, order total, taxes, currency, return URLs, etc. PayPal will respond with a token that you need to use in future requests.
- Send the user to PayPal along with the process token from the prior request.
- The user completes their purchase and are redirected back to your site (along with the process token).
- Look up the details for the token (another PayPal request) and verify that the PayPal process happened successfully.
- Run the actual purchase request to PayPal, again verifying that it succeeded.
In terms of Rails and ActiveMerchant, this would look something like the following:
def checkout
# ...
if @order.valid?
# Here's step 1
response = paypal_gateway.setup_purchase(
@order.total_in_cents,
order_paypal_params(@order).merge(
return_url: order_receipt_url, # Remember to use _url here and not _path (must be absolute)
cancel_return_url: order_cancel_url,
)
)
if response.success?
@order.update(
paypal_correlation_id: response.params["CorrelationID"],
paypal_token: response.token
)
# Here's step 2
return redirect_to paypal_gateway.redirect_url_for(response.token)
end
end
# ...
end
def receipt
# Here we're returning from step 3
@order = Order.find_by(paypal_token: params[:token])
catch(:failure) do
if @order.payment_processed
@error_message = "This order has already been paid for, no further action is necessary"
throw :failure
end
# Here's step 4
purchase_details = paypal_gateway.details_for(params[:token])
unless purchase_details.success?
@error_message = purchase_details.message
throw :failure
end
# Finally, step 5
purchase_response = paypal_gateway.purchase(
@order.total_in_cents,
order_paypal_params(@order).merge(
token: params[:token],
payer_id: purchase_details.payer_id,
)
)
unless purchase_response.success?
@error_message = purchase_details.message
throw :failure
end
@order.update(
payment_processed: Time.zone.now,
amount_paid: @pack_order.total,
)
end
# ...
end
private
def order_paypal_params(order)
{
items: [{
name: "An Awesome Shirt",
quantity: 1,
amount: order.total_in_cents,
description: "This is the coolest shirt ever, woo!",
}],
ip: request.remote_ip,
allow_guest_checkout: true,
currency: "CAD",
invoice_id: order.id, # To make correlating easier later, if debugging
}
end
def paypal_gateway
@paypal_gateway ||= ActiveMerchant::Billing::PaypalExpressGateway.new(
login: Configuration.paypal_username,
password: Configuration.paypal_password,
signature: Configuration.paypal_signature,
)
end
Some things to note:
- All monetary values passed to PayPal are in cents (for USD and CAD. I'm unsure of cases in currencies where the common monetary unit is non-decimal).
- The total that is passed in as the first parameter to both
setup_purchase
andpurchase
needs to match the array of items, plus taxes, shipping, etc. If it doesn't match up perfectly you're in for a lot of headache. - The
response
object that you get back from ActiveMerchant - to be exact, an instance ofActiveMerchant::Billing::PaypalExpressResponse
- has aerror_code
field that (at the time of writing) appears to always filled out, even if you have a successful request. You're going to want to checkresponse.success?
. - Especially as you initially put something like this into production, make sure to log, log, log. Don't log anything sensitive, but having lots of information, such as a full trace of the PayPal requests, can be very helpful if you're trying to figure out why a certain purchase isn't going through.
- This is condensed into a single post as much as possible so I don't forget this if I ever need it in the future. Don't need to waste 3 hours again :)
Top comments (4)
Thank you, Thank you. I was missing the whole Step 5 purchase block, purchases were succeeding, but no money was ever sent...because my code was never asking for it. Your example breaks down the mystery of the "gateway" in a way that my feeble mind can comprehend. Thanks again for putting this together.
Mind if I ask a question, since you already did the integration. I'm testing in my project, and I noticed when the user goes to PayPal page to enter details, there is a message saying
“You’ll be able to review your order before you complete your purchase”
However user does not get a chance to review the order, and is charged instantly. This seems to be a common issue with other merchants as well
paypal-community.com/t5/Payments/q...
Any idea? Are we supposed to show another Confirm You Want To Pay after Receipt page? This API Flow Page does seem to suggest it as well:
developer.paypal.com/docs/archive/...
Though I do recall often paying directly without another trip to the Merchant site. Frankly that extra step seems unnecessary, and likely to be an annoyance.
Love to hear your thoughts.
Thanks a lot. Really helpful!
thanks alot it help so much