DEV Community

Galih Muhammad
Galih Muhammad

Posted on

End-to-end Paypal Checkout with Rails (Part 3 of 3)

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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.

The receiving of the callback from paypal

onApprove: async (data, actions) => {
  const order = await actions.order.capture();

Enter fullscreen mode Exit fullscreen mode

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.

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

// 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'
              }
            )
          });
Enter fullscreen mode Exit fullscreen mode

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.

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;
          }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (1)

Collapse
 
ripchanskiy profile image
Ripchanskiy

Hello Galih.
Thank you for interesting article.
I don't know vue and maybe i just missed something.
But how can we protect against fake requests? For instance if i send this request manually from console.Where in controller we check that payment was successful ?

      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'
          }
        )
      });
Enter fullscreen mode Exit fullscreen mode