ActiveMerchant and PayPal Express Checkout
Tags: rails, activemerchat, ruby, paypal
Published: 2020-02-18 05:02:32

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.

1. 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.
2. Send the user to PayPal along with the process token from the prior request.
3. The user completes their purchase and are redirected back to your site (along with the process token).
4. Look up the details for the token (another PayPal request) and verify that the PayPal process happened successfully.
5. 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:

```ruby
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:

1. 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).
2. The total that is passed in as the first parameter to both `setup_purchase` and `purchase` 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.
3. The `response` object that you get back from ActiveMerchant - to be exact, an instance of `ActiveMerchant::Billing::PaypalExpressResponse` - has a `error_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 check `response.success?`.
4. 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.
5. 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 :)