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)
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.
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.
Thanks a lot. Really helpful!
thanks alot it help so much