Tyler Scott Williams
Posted on May 13, 2020
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:
- Takes custom payment amounts
- Takes credit cards from customers
- 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:
- Get the publishable key from the
meta
tag - Get the CSRF token
- Initialize Stripe Elements
- Mount Stripe Elements to the
card
element - Set up error checking on the
card-element
to provide feedback if users don't provide well formatted card information - Set up an event listener for the form
submit
event - Tell the
submit
handler to POST to the/payments/payment_intents
- Receive the
PaymentIntent
from thePaymentIntentsController
- Submit the
PaymentIntent
to Stripe - 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:
- Run
rails s
- Visit localhost:3000/payments/checkouts/new
- Input an amount, fill in a test card number,
- 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.
Posted on May 13, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.