Easy Paypal Checkout in Rails using Smart Payment Buttons

Alt Text

Before we start, don't forget to run these commands for your convenience:

bundle install
yarn
rake db:create
rake db:migrate
rake db:seed

SETUP

Get Paypal Client ID and Secret

  • Get sandbox Client ID
  • Create application.yml using figaro and use it to store the ID, so that you don't (accidentally) commit the id and secret to the public.
bundle exec figaro install

config/application.yml

development:
  paypal_client_id: PUT_YOUR_PAYPAL_SANDBOX_CLIENT_ID_HERE
  paypal_client_sec: PUT_YOUR_PAYPAL_SANDBOX_CLIENT_SECRET_HERE

test:
  paypal_client_id: PUT_YOUR_PAYPAL_SANDBOX_CLIENT_ID_HERE
  paypal_client_sec: PUT_YOUR_PAYPAL_SANDBOX_CLIENT_SECRET_HERE

PRO TIP: Please remember to RESTART your server ^_^

Integrate Paypal smart button to html views

  • Add the paypal button container on the product item html partial, below the TODO: comment

app/views/store/products/_product.html.erb

    <!-- TODO: Put the paypal button here -->
    <div id="paypal-button-container"></div>
  • Add script source from PAYPAL to the bottom of the body element, after the footer

app/views/layouts/store/application.html.erb

  <script
    id="paypal-sdk-js-src"
    src="https://www.paypal.com/sdk/js?client-id=paypal_client_id"
    data-sdk-integration-source="button-factory"
  >
  </script>
  <script>
    paypal.Buttons({
      env: 'sandbox', // Valid values are sandbox and live.
      createOrder: function(data, actions) {
      // This function sets up the details of the transaction, including the amount and line item details.
      return actions.order.create({
        purchase_units: [{
          amount: {
            value: '0.01' // This will be replaced, I promise
          }
        }]
      });
    },
    onApprove: async (data) => {}
  }).render('#paypal-button-container');
  </script>
  • Reference the client id from application.yml app/views/layouts/store/application.html.erb
  <--- REDACTED --->
  <script
    id="paypal-sdk-js-src"
    src="https://www.paypal.com/sdk/js?client-id=<%= ENV['paypal_client_id'] %>"
    data-sdk-integration-source="button-factory"
  ></script>
  <--- REDACTED --->

After adding this, you can reload and you will see that the Paypal button shows up and working. You can even click it and start making (mock) payment!

Alt Text

If you click the Pay with paypal button: Alt Text

And if you click the "Debit or Credit Card" button, it will render a form like this for free: Alt Text

But we are not done, since completing this checkout will not trigger anything (even though it will be successful) and the checkout amount is mocked to USD0.01 at the moment. So the rest of the post, will be for getting our checkout working as normal, even production-ready.

Excited? Let's get on with it, then.

PROGRESSING

Convert the button to vue component

Remove the second script that we added on the layout template so that it will only look like this:

app/views/layouts/store/application.html.erb

  </footer>

    <script
    id="paypal-sdk-js-src"
    src="https://www.paypal.com/sdk/js?client-id=<%= ENV['paypal_client_id'] %>"
    data-sdk-integration-source="button-factory"
  >
  </script>
</body>

Only keep the script source from Paypal, then we add the button container, by replacing the previous button html on the product item partial html

app/views/store/products/_product.html.erb

    <!-- TODO: Put the paypal button here -->
    <div class="buynow">
      <paypal-button
        refer="paypal-container-<%= product.id.to_s %>"
      />
    </div>

Now if you refresh, the button will disappear, our mission for the next 5 mins is to make that re-appear

  • Intro for the vue pack: store.js

This is the current state of the Vue pack that will be responsible to hold all the javascripts needed to easily get our Paypal button to be dynamic.

app/javascript/packs/store.js

import Vue from 'vue/dist/vue.esm'

document.addEventListener('DOMContentLoaded', () => {
  var storeList = new Vue({
    el: '', // Add the css identifier for the div that will load the paypal button
    components: {
      // Add paypal button here
    }
  })
})

This Vue pack is already hooked to our rails HTML template on the layout template for the storefront.

app/views/layouts/store/application.html.erb:13

%= javascript_pack_tag     'store' %>
  • Add Paypal JS SDK using yarn
    yarn add @paypal/sdk-client
    

then add to the store.js

import Vue from 'vue/dist/vue.esm'
// add this single line below here to use the Paypal JS SDK we've just installed
import { unpackSDKMeta } from '@paypal/sdk-client';

document.addEventListener('DOMContentLoaded', () => {
  • Create the vue component: paypal button

app/javascript/components/paypal_button.vue

<template>
  <div :id="refer"></div>
</template>

<script>
export default {
  props: {
    // This is for giving dynamic container css 'ID' to be called by the Paypal button renderer
    refer: {
      type: String,
      required: true
    }    
  },
  data() {
  },
  mounted: function() {
  },
  computed: {
  },
  methods: {
  }
}
</script>

The last thing for this step is to hook this newly-created component onto the store.js pack

import Vue from 'vue/dist/vue.esm'
import { unpackSDKMeta } from '@paypal/sdk-client';
// Add this single line below
import PaypalButton from '../components/paypal_button.vue';

document.addEventListener('DOMContentLoaded', () => {
  var storeList = new Vue({
    el: '#store-list', // Add the css identifier for the div that will load the paypal button
    components: {
      // Add paypal button here
      PaypalButton
    }
  })
})

Notice that we have just added this line:

    components: {
      PaypalButton
    }

and this line:

el: '#store-list',

but for this line to be working, we need to remember to add this element identifier onto the rails template also

app/views/store/products/index.html.erb

<div
  class="section section-demo"
  id="store-list" <!-- This is what we add -->
>
  <div class="container">
    <%= render partial: "product", collection: @products, as: :product %>
  </div>
</div>

Two lines, that's it. We will not be adding anymore to this file.

So the setup is ready, all we need to do now is to get the component working.

  • Setup the vue component: paypal button

app/javascript/components/paypal_button.vue


// REDACTED //

<script>
export default {
  props: {
    refer: {
      type: String,
      required: true
    },
    // This is where will pass more product attributes to be used in this vue component from the rails html template
  },
  data() {
    return {
      // Defaults template for the order JSON payload that will be sent to Paypal
      order: {
        description: "",
        amount: {
          currency_code: "",
          value: 0
        }
      }
    };
  },
  mounted: function() {
    // IMPORTANT: this method causes the paypal button be loeaded and rendered
    this.setLoaded();
  },
  computed: {
    selectorContainer() {
      return '#' + this.refer;
    }
  },
  methods: {
    setLoaded: function() {
      paypal
        .Buttons({
          createOrder: (data, actions) => {
            return actions.order.create({
              purchase_units: [
                this.order
              ]
            });
          },
          onApprove: async (data, actions) => {
          },
          onError: err => {
            console.log(err);
          }
        }).render(this.selectorContainer);
    }
  }
};
</script>

Up to this point, your component should be ok enough that it will re-appear and working once more.


title: End-to-end Paypal Checkout with Rails (Part 3 of 3) series: Easy Paypal Checkout in Rails published: false description: tags:

//cover_image: direct_url_to_image.jpg

If you want to code along with this post, you can do so by checking out from this commit on indiesell repo.

Getting the order creation to Paypal dynamic

First of all, we want to ask payment from our customers according to the product they choose to buy, right? So that is our first objective, and where we'll capitalize on our hardwork of turning the paypal button into Vue component.

We can easily pass the attributes from our products, that were created on the backend, to the front-end, which is our Paypal button:

From:

app/views/store/products/_product.html.erb

    <!-- TODO: Put the paypal button here -->
    <div class="buynow">
      <paypal-button
        refer="paypal-container-<%= product.id.to_s %>"
      />
    </div>

To:

app/views/store/products/_product.html.erb

    <!-- TODO: Put the paypal button here -->
    <div class="buynow">
      <paypal-button
        refer="paypal-container-<%= product.id.to_s %>"
        currency-code="<%= product.price_currency %>"
        price-str="<%= product.price.to_s %>"
        product-description="<%= product.name %>"
        product-id="<%= product.id %>"
      />
    </div>

Here we have added the currency, price, product description, and also the id of the product, so that it can be used in the component.

app/javascript/components/paypal_button.vue

export default {
  props: {
    refer: {
      type: String,
      required: true
    },
    // Pass the product attributes to be used here
    currencyCode: {
      type: String,
      required: false,
      default() {
        return 'USD'
      }
    },
    priceStr: {
      type: String, // should be like "100.00"
      required: true
    },
    productDescription: {
      type: String,
      required: true
    },
    productId: {
      type: String,
      required: true
    }
  },

// REDACTED

The data that we pass from the rails template as props, will override our default Paypal order payload to trigger checkout process using the smart payment buttons:

app/javascript/components/paypal_button.vue

// REDACTED
  mounted: function() {
    // These 3 lines are what we add here
    this.order.description          = this.productDescription;
    this.order.amount.currency_code = this.currencyCode;
    this.order.amount.value         = Number(this.priceStr);

    // IMPORTANT: to cause the paypal button be loeaded and rendered
    this.setLoaded();
  },

// REDACTED

Now if you refresh, when you click one of the payment buttons, you will see that the amount we charge our customers is dynamic, as per set for the product selected.

So by this point, we are able to ask payment from our customers correctly, but the any successful, valid payment, will still not trigger anything on our app. So let's change that on!

Setup the Paypal Capture endpoint to capture payment

First, because we want to also store the successful payments that our customers made on Paypal from the smart buttons, we need to record it as "Purchase" on our DB. And we can achieve just that by creating an endpoint to do just that, and hook it to the "onApprove" callback from the smart button.

So the implementation is up to you, but for indiesell, I implemented something like this:

app/controllers/api/v1/store/paypal_purchases_controller.rb

# frozen_string_literal: true

module API
  module V1
    module Store
      class PaypalPurchasesController < ApplicationController
        # We'll remove this line below, i promise to you
        skip_before_action :verify_authenticity_token

        def create
          # TODO: this is where we put the magic
        end
      end
    end
  end
end

app/controllers/api/v1/store/paypal_purchases_controller.rb

def create
  # TODO
  purchase                      = Purchase.new
  purchase.gateway_id           = 1
  purchase.gateway_customer_id  = params[:customer_id]
  purchase.customer_email       = params[:customer_email]
  purchase.product_id           = params[:product_id]
  purchase.token                = params[:token]
  purchase.is_paid              = params[:is_successful]

  # Because price_cents is string of "20.00", we need to 
  # parse the string to money. To do that we need to build the compatible money string,
  # should be like "USD 20.00"
  money_string = "#{params[:price_currency]} #{params[:price_cents]}"
  parsed_money = Monetize.parse money_string

  purchase.price_cents          = parsed_money.fractional # 2000
  purchase.price_currency       = parsed_money.currency.iso_code # USD

  if purchase.save
    render status: :ok, json: { purchase_code: purchase.id }
  else
    render status: :unprocessable_entity, json: {}
  end
end

So on the endpoint, we should be prepping the purchase record based on the payload that we receive from the "onApprove" callback on the paypal_button.vue.

After prepping, we then try to save it. If it is successful, then we declare status 200, if not then 422, as the json response.

Now that the endpoint is ready, let's hook it to the vue component to have an end to end process setup.

app/javascript/components/paypal_button.vue


methods: {
  setLoaded: function() {
    paypal
      .Buttons({

        // REDACTED

        onApprove: async (data, actions) => {
          const order = await actions.order.capture();
          // for complete reference of order object: https://developer.paypal.com/docs/api/orders/v2

          const response = await fetch('/api/v1/store/paypal_purchases', {
            method:   'POST',
            headers:  {
              "Content-Type": "application/json"
            },
            body:     JSON.stringify(
              {
                price_cents:    this.priceStr,
                price_currency: this.currencyCode,
                product_id:     this.productId,
                token:          order.orderID,
                customer_id:    order.payer.payer_id,
                customer_email: order.payer.email_address,
                is_successful:  order.status === 'COMPLETED'
              }
            )
          });

          const responseData = await response.json();
          if (response.status == 200) {
            window.location.href = '/store/purchases/' + responseData.purchase_code + '/success';
          } else {
            window.location.href = '/store/purchases/failure?purchase_code=' + responseData.purchase_code;
          }
        },
        onError: err => {
          console.log(err);
        }
      }).render(this.selectorContainer);
  }
}

I know it seems a lot, and I do apologize if this step is a bit overwhelming. But don't worry, we'll discuss it one by one.

  1. The receiving of the callback from paypal ```js onApprove: async (data, actions) => { const order = await actions.order.capture();
So the order constant is basically the "capture" result, meaning that when the customer that checks out using our Smart Payment buttons, Paypal knows to where the successful payment callback should be posted to, we just need to capture it and store it.

2. The acknowledgment of successful payment for our app
Now that Paypal knows our customer has successfully paid the bill, then we need to acknowledge it also, hence this action of sending POST request to the endpoint we created earlier

```js
// REDACTED
          const response = await fetch('/api/v1/store/paypal_purchases', {
            method:   'POST',
            headers:  {
              "Content-Type": "application/json"
            },
            body:     JSON.stringify(
              {
                price_cents:    this.priceStr,
                price_currency: this.currencyCode,
                product_id:     this.productId,
                token:          order.orderID,
                customer_id:    order.payer.payer_id,
                customer_email: order.payer.email_address,
                is_successful:  order.status === 'COMPLETED'
              }
            )
          });

Take a good look on the JSON object with the :body key, that is essentially the payload that we will be processing on the endpoint that we made. So you can just customize, add, or remove any data as you see fit.

  1. Notify/Redirect user
          const responseData = await response.json();
          if (response.status == 200) {
            window.location.href = '/store/purchases/' + responseData.purchase_code + '/success';
          } else {
            window.location.href = '/store/purchases/failure?purchase_code=' + responseData.purchase_code;
          }

So again, this is entirely up to you, where or how you want to notify your customers that the payment, aside from being completed in Paypal, have also been acknowledged by your database.

In the case of Indiesell, I redirect the customers to success page if successful and failure page if there is something wrong on the endpoint. The successful and failure page have been made beforehand, so I will not cover that on this post.

Finishing: Enabling submitting CSRF token

So last but not least, remember about the promise I made to you on this post earlier? app/controllers/api/v1/store/paypal_purchases_controller.rb

# redacted
      class PaypalPurchasesController < ApplicationController
        skip_before_action  :verify_authenticity_token

        def create
          # redacted

Yes, that bit. That bit actually is unsafe for production, since it bypasses one of security features from Rails. I skipped that bit just to keep things simpler to complete our checkout development, but now we're done, let's get on it then.

First, remove that unsafe line.

app/controllers/api/v1/store/paypal_purchases_controller.rb

# redacted
      class PaypalPurchasesController < ApplicationController
        def create
          # redacted

Now with this, our checkout system will fail once more during the capture callback. What we need to do is to submit CSRF token created by rails for POST request that we send to our endpoint

So first we create a mixin to specifically fetch the CSRF token from the HTML: app/javascript/mixins/csrf_helper.js

var CsrfHelper = {
    methods:{

      findCsrfToken() {
        let csrf_token_dom = document.querySelector('meta[name="csrf-token"]');
        let csrf_token = "";

        if (csrf_token_dom) {
          csrf_token = csrf_token_dom.content;
        }

        return csrf_token;
      }

    }
};
export default CsrfHelper;

Then, we must not forget to import that mixin and declare it in our paypal_button.vue component

app/javascript/components/paypal_button.vue

<template>
  <div :id="refer"></div>
</template>

<script>
// MIXINS
// For grabbing the CSRF token to be used to submit to internal API endpoint
import CsrfHelper from '../mixins/csrf_helper.js';
export default {
  mixins:[CsrfHelper],

Once done, use it by calling it before we send the POST request:

app/javascript/components/paypal_button.vue

// REDACTED
const response = await fetch('/api/v1/store/paypal_purchases', {
  method:   'POST',
  headers:  {
    "Content-Type": "application/json",
    "X-CSRF-Token": this.findCsrfToken()  // taken from the mixins/csrf_helper.js
  },
  body:     JSON.stringify(

// REDACTED

And we're done. If you have been coding along, please refresh the page and try to complete a purchase.

Or if you want to check the source code for this series of posts, you can do so by checking out this branch on the indiesell repo.

Happy coding, cheers!