I maintain a handful of Ruby on Rails applications for my clients. Most of them need to handle payments. My go-to payment processor is Stripe. Stripe makes payments easy, but still requires some setup and configuration. I've written the same one-time payment feature about five times with slight variations, so I wanted to make it easier to repeat.

This sort of abstraction is a good candidate for a Rails engine.

Engines are basically sub-applications that provide functionality to a host application. They're great if you have some set of features that could reasonably be separated into their own application, but require a high amount of interoperability with your main application. (Thanks to Noah Gibbs for the phrasing on that one).

I wrote a proof-of-concept engine that:

  1. Takes custom payment amounts
  2. Takes credit cards from customers
  3. Creates charges using Stripe

With that engine in hand, I can include it in any future project, provide the Stripe credentials, and save quite a bit of time.

Here's how you can do something similar.

Generate the engine

In the command line, run the rails plugin generator with the --mountable flag.

rails plugin new payments --mountable 
cd payments

Update the gemspec

The engine gemspec will have some TODO items in payments.gemspec. Fill them in:

# payments.gemspec
# . . . other config here
  spec.homepage    = "https://ogdenstudios.xyz"
  spec.summary     = "Adds Stripe one-time payment capability"
  spec.description = "Adds Stripe one-time payment capability"
# . . . the rest of the gemspec 

Then run bundle. Once the installation is complete, check the engine is running by running rails s in the console and visiting localhost:3000. The standard Rails welcome page should appear.

Add the Stripe gem

Adding gems to a Rails engine is a little different than adding them to a Rails app. We have to use the gemspec instead of a Gemfile. To add the Stripe gem to the engine, update payments.gemspec.

# payments.gemspec 
# . . . other configuration here
  spec.add_dependency "stripe"
# . . . the rest of the gemspec

Then bundle again.

Create the payment_intents controller

Stripe uses PaymentIntents to process payments. The idea is to take some information from the customer, combine it with your credentials, and pass back an object that represents an intent to make a payment. Then we submit that intention to Stripe. Stripe verifies all the information is correctly formatted, and handles the final processing.

Let's make a PaymentIntentsController. This controller will only have one method for now, the create method. We'll use it to create a PaymentIntent and return it as a JSON response to our engine's view, which will then pass that PaymentIntent to Stripe for processing.

Create a file at app/controllers/payments/payment_intents_controller.rb. Inside:

# app/controllers/payments/payment_intents_controller.rb
require 'Stripe'
module Payments
    class PaymentIntentsController < ApplicationController
        def create 
            Stripe.api_key = Rails.configuration.x.stripe.secret
            @intent = Stripe::PaymentIntent.create({
                amount: params[:amount],
                currency: 'usd',
            })
            render json: @intent
        end
    end
end

This method configures the Stripe object, available through the Stripe gem, with the secret key. It creates a PaymentIntent from the supplied params hash and returns the intent as a JSON response.

Add the PaymentIntent create route

We have to provide access to the controller method through our routing. In config/routes.rb, add the create route for payment_intents. Like this:

# config/routes.rb
Payments::Engine.routes.draw do
    resources :payment_intents, only: [:create]
end

Add a checkout controller

PaymentIntentsController only handles creating the PaymentIntent. We also need a view for users to interact with the engine and make payments. We can call that a Checkout, and manage it through a CheckoutsController. Create a file at app/controllers/payments/checkouts_controller.rb.

# app/controllers/payments/checkouts_controller.rb
module Payments
    class CheckoutsController < ApplicationController
        def new 
        end
    end
end

This controller is only responsible for rendering the view that creates a new checkout, so we only need the new method.

Route to the checkouts controller

Update config/routes.rb so it looks like this:

#config/routes.rb
Payments::Engine.routes.draw do
    resources :checkouts, only: [:new]
    resources :payment_intents, only: [:create]
end

Create a view for new checkout

This is where users will make payments. Create a file at app/views/payments/checkouts/new.html.erb and use the boilerplate form provided through the Stripe docs. Stripe assumes you have hard-coded prices for your one-time payments. This engine allows users to provide a custom amount, so we add the amount input, which we'll use to create the PaymentIntent. It also has a custom pattern to format the input and require only numbers (with up to two decimal places).

<!-- app/views/payments/checkouts/new.html.erb -->
<form id="payment-form">
  <div>
    <label for="amount">Amount (USD)</label>
    <input
        id="amount"
        type="tel"
        placeholder="100.00"
        required=""
        autocomplete="tel"
        pattern="(?=.*?\d)^\$?(([1-9]\d{0,2}(\d{3})*)|\d+)?(\.\d{1,2})?$"
    />
    </div>
    <div id="card-element">
      <!-- Elements will create input elements here -->
    </div>
    <!-- We'll put the error messages in this element -->
    <div id="card-errors" role="alert"></div>
    <button id="submit">Pay</button>
</form>

Add Stripe Elements to the application layout

In order for Stripe to work on the client side, we need to include Stripe Elements in the view. We can set that up in app/views/layouts/payments/application.html.erb

<!-- app/views/layouts/payments.application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Payments</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "payments/application", media: "all" %>
    <script src="https://js.stripe.com/v3/"></script>
    <meta name="stripePublishableKey" content="<%= Rails.configuration.x.stripe.publishableKey %>" />
  </head>
  <body>
    <%= yield %>
  </body>
</html>

We also add a meta tag to embed the Stripe publishable key. We'll use that in our client-side JavaScript.

The client-side JavaScript

We'll add a script tag at the bottom of app/views/payments/checkouts/new.html.erb. The script will provide a basic user story for the checkout:

  1. Get the publishable key from the meta tag
  2. Get the CSRF token
  3. Initialize Stripe Elements
  4. Mount Stripe Elements to the card element
  5. Set up error checking on the card-element to provide feedback if users don't provide well formatted card information
  6. Set up an event listener for the form submit event
  7. Tell the submit handler to POST to the /payments/payment_intents
  8. Receive the PaymentIntent from the PaymentIntentsController
  9. Submit the PaymentIntent to Stripe
  10. Alert the user based on errors or successes

After the closing form tag, add:

<script>
  var publishableKey = document.getElementsByName('stripePublishableKey')[0].content
  var csrftoken = document.getElementsByName("csrf-token")[0].content;
  var stripe = Stripe(publishableKey);
  var elements = stripe.elements();
  var style = {
      base: {
          color: "#32325d",
      }
  };

  var card = elements.create("card", { style: style });
  card.mount("#card-element");

  document.getElementById('card-element').addEventListener('change', function (event) {
      var displayError = document.getElementById('card-errors');
      if (event.error) {
          displayError.textContent = event.error.message;
      } else {
          displayError.textContent = '';
      }
  });

  var form = document.getElementById('payment-form');

  form.addEventListener('submit', function (ev) {
      ev.preventDefault();
      // Get the data from the form
      var data = {
          amount: document.getElementById('amount').value * 100
      }
      // Get the client secret
      fetch('/payments/payment_intents', {
          method: 'POST',
          headers: {
              "Content-Type": "application/json",
              "X-CSRF-Token": csrftoken,
          },
          body: JSON.stringify(data),
      }).then(function (response) {
          response.json().then(function (data) {
              confirmPayment(data.client_secret);
          })
      })
      // Confirm the payment
      function confirmPayment(clientSecret) {
          stripe.confirmCardPayment(clientSecret, {
              payment_method: {
                  card: card
              }
          }).then(function (result) {
              if (result.error) {
                  // Show error to your customer (e.g., insufficient funds)
                  window.alert(result.error.message);
              } else {
                  // The payment has been processed!
                  if (result.paymentIntent.status === 'succeeded') {
                      window.alert('Succeeded!')
                      // Show a success message to your customer
                      // There's a risk of the customer closing the window before callback
                      // execution. Set up a webhook or plugin to listen for the
                      // payment_intent.succeeded event that handles any business critical
                      // post-payment actions.
                  }
              }
          });
      }
  });
</script>

Notice when we set the amount, we multiply it by 100:

amount: document.getElementById('amount').value * 100

This is because Stripe handles amounts as integer values in cents. The input is formatted for dollars, so we multiply by 100 to get the correct amount of cents to pass to the create method in PaymentIntentsController.

Try it out in a host application

Make a new Rails application. In the console:

cd ..
rails new hostapp
cd hostapp

Mount the engine. In hostapp/Gemfile:

gem 'payments', path: 'full/path/to/payments'

Run bundle in the command line to install everything.

Set up the engine routes

In hostapp/config/routes.rb, add the routes:

# hostapp/config/routes.rb
Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  mount Payments::Engine, at: "/payments"
end

Link the engine stylesheet

The stylesheet from the engine needs to be linked to the hostapp for asset compilation. In hostapp/app/assets/stylesheets/application.css, add //= link payments/application.css.

The file should look like this entirely:

/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 //= link payments/application.css
 */

Securely store your Stripe API keys

Use Rails credentials to store your Stripe keys. For now, we can just the the test publishable key and the test secret key.

In the command line:

EDITOR="vim" rails credentials:edit

Then edit the file to add the following YAML:

stripe:
    test_publishable_key: your_key_here
    test_secret_key: your_key_here

Add the test keys to the development environment

In hostapp/config/environments/development.rb:

# `hostapp/config/environments/development.rb`
# . . . other configuration here
  config.x.stripe.publishableKey = Rails.application.credentials.stripe[:test_publishable_key] 
  config.x.stripe.secret = Rails.application.credentials.stripe[:test_secret_key]

Test it out

From within the hostapp directory:

  1. Run rails s
  2. Visit localhost:3000/payments/checkouts/new
  3. Input an amount, fill in a test card number,
  4. Submit the form and check your Stripe dashboard for the test charges.

Next steps

This engine isn't quite ready for primetime. This is mostly a proof-of-concept that I plan to expand on for personal use. Here are some things I might do to make it a little more versatile:

  • Create an installation rake task to handle the routing and asset linking with a command like rails generate payments:install
  • Create some generators to render the views and controllers for further configuration, like Devise does.
  • Handle the client-side JavaScript more gracefully. I don't love writing it inline, but I had trouble including the JavaScript correctly through the generator. I need to learn how to do that, or consider packaging it up separately to drop in as a separate JS dependency.