Stripe has a great API to manage subscription payments, we will take advantage of it to implement recurring subscriptions in Rails 5.
Using the Stripe API allows us to avoid having to store sensitive customer information like (credit card number or cvv), and the APIs are already set up to handle complex cases such as update plans, manage subscriptions, trigger refunds, and more.
We will set up the Stripe API to handle our subscriptions. We also need Stripe to tell us of ongoing payments and the failure of ongoing payments. This will be possible through webhooks, which are endpoints on our application that Stripe will use to send us details of transactions when changes happen via Stripe.
NOTE: We'll create plans, subscriptions and customers locally since we need to have that data in our application we also want to send that data to Stripe so we can manage it through the Stripe API.
NOTE: It's important to say that we are not going to store any credit card information in our systems.
Let's create the plans table & model. Feel free to add more columns to match your own business requirements.
rails generate model plan payment_gateway_plan_identifier:string name:string \
price:monetize interval:integer interval_count:integer \
status:integer description:text
app/models/plan.rb
class Plan < ApplicationRecord enum status: {inactive: 0, active: 1} enum interval: {day: 0, week: 1, month: 2, year: 3} monetize :price_cents def end_date_from(date = nil) date ||= Date.current.to_date interval_count.send(interval).from_now(date) end
end
Let's add the Stripe customer id
to our users table. In case you don't know, we need a Stripe customer in order to associate it to a Stripe plan.
NOTE: I assume that you have a User model already in your application
class AddPaymentGatewayCustomerIdentifierToUser < ActiveRecord::Migration[5.0] def change add_column :users, :payment_gateway_customer_identifier, :string end
end
We need a Subscription model in order to track subscriptions locally, off course we will also create those subscriptions on Stripe.
rails generate model subscription user:references \
plan:references start_date:date end_date:date \
status:integer payment_gateway:string payment_gateway_subscription_id:string
app/models/subscription.rb
class Subscription < ApplicationRecord belongs_to :user belongs_to :plan enum status: {active: 0, inactive: 1, canceled: 2}
end
In order to subscribe to a plan, we need to list all the active plans, here is the controller.
app/controllers/plans_controller.rb
class PlansController < ApplicationController def index @plans = Plan.active fresh_when(@plans) end
end
Display all the plans information and a link to the subscription page.
app/views/plans/index.html.erb
<h2>Plans</h2>
<% @plans.each do |plan| %> <h4><%= plan.name %></h4> <p><%= plan.description %></p> <p><%= humanized_money_with_symbol(plan.price) %></p> <%= link_to("Subscribe to #{plan.name.titleize} Plan", new_plan_subscription_path(plan)) %>
<% end %>
Display a form with credit card details information like Card number, CVC, Expiration Month and Year.
Using Stripe JS will allow us to get the payment errors (if any).
app/views/subscriptions/new.html.erb
<%= form_tag subscription_path, id: "subscription-form" do %> <div class="card-fields"> <span class="subscription-errors"></span> <label> <span>Card Number</span> <input value="4242 4242 4242 4242" type="text" size="20" data-stripe="number"/> </label> <label> <span>CVC</span> <input value="123" type="text" size="4" data-stripe="cvc"/> </label> <label> <span>Expiration</span> <input value="12" type="text" size="2" data-stripe="exp-month"/> <input value="2020" type="text" size="4" data-stripe="exp-year"/> </label> </div> <button type="submit">Submit Payment</button>
<% end %>
Here we will use jQuery and Stripe JS (V2) in order to generate the Stripe Token and validate the card information. In case there are no eerors the form will submit to our backend API.
app/assets/javascripts/subscriptions.js
var stripeResponseHandler; jQuery(function() { Stripe.setPublishableKey($("meta[name='stripe-key']").attr("content")); $('#subscription-form').submit(function(event) { var $form; $form = $(this); // Disable the submit button to prevent repeated clicks $form.find('button').prop('disabled', true); // Prevent form submittion Stripe.card.createToken($form, stripeResponseHandler); return false; });
}); stripeResponseHandler = function(status, response) { var $form, token; $form = $('#subscription-form'); if (response.error) { $form.find('.subscription-errors').text(response.error.message); $form.find('button').prop('disabled', false); } else { token = response.id; $form.append($('<input type="hidden" name="payment_gateway_token" />').val(token)); $form.get(0).submit(); }
};
We will insert the Stripe JS (V2) script tag so we can generate the payment_gateway_token
, which is going to be needed to create a Stripe subscription.
Basically a Stripe Token is a key that represent our credit card information.
NOTE: I'm using Rails 5.x encrypted credentials to get the stripe public key content
app/views/layouts/application.html.erb
<html>
<head> <title>Subscriptions</title> <%= stylesheet_link_tag 'application', media: 'all' %> <%= javascript_include_tag 'application', 'https://js.stripe.com/v2/' %> <%= csrf_meta_tags %> <%= tag :meta, name: "stripe-key", content: Rails.application.credentials.stripe_public %>
</head>
<body> <%= yield %>
</body>
</html>
Basically this controller will use a service that takes care of processing the payment (we will see the service object later).
app/controllers/subscriptions_controller.rb
class SubscriptionsController < ApplicationController rescue_from PaymentGateway::CreateSubscriptionServiceError do |e| redirect_to root_path, alert: e.message end before_action :authenticate_user! before_action :load_plan def new @subscription = Subscription.new end def show @subscription = current_user.subscriptions.find(params[:id]) end def create service = PaymentGateway::CreateSubscriptionService.new( user: current_user, plan: @plan, token: params[:payment_gateway_token]) if service.run && service.success redirect_to plan_subscription_path(@plan, service.subscription), notice: "Your subscription has been created." else render :new end end private def load_plan @plan = Plan.find(params[:plan_id]) end
end
We need a wrapper between our application and the Stripe library. We are going to create a class to delegate all the Stripe methods. It's going to be worth, trust me!
app/services/payment_gateway/stripe_client.rb
class PaymentGateway::StripeClient def lookup_customer(identifier: ) handle_client_error do @lookup_customer ||= Stripe::Customer.retreive(identifier) end end def lookup_plan(identifier: ) handle_client_error do @lookup_plan ||= Stripe::Plan.retreive(identifier) end end def lookup_event(identifier: ) handle_client_error do @lookup_event ||= Stripe::Event.retreive(identifier) end end def create_customer!(options={}) handle_client_error do Stripe::Customer.create(email: options[:email]) end end def create_plan!(product_name, options={}) handle_client_error do Stripe::Plan.create( id: options[:id], amount: options[:amount], currency: options[:amount] || "usd", interval: options[:interval] || "month", product: { name: product_name } ) end end def create_subscription!(customer: , plan: , source: ) handle_client_error do customer.subscriptions.create( source: source, plan: plan.id ) end end private def handle_client_error(message=nil, &block) begin yield rescue Stripe::StripeError => e raise PaymentGateway::StripeClientError.new(e.message) end end
end
We are going to consume the Stripe Client methods through another class. Why? First. This will help us a lot in case we like to switch to another payment gateway. Second. The code is going to be extremily easy to test with this design. Another reason is because handling exceptions in this way it's pretty nice since every level has its own exceptions.
app/services/payment_gateway/client.rb
class PaymentGateway::Client attr_accessor :external_client def initialize(external_client: PaymentGateway::StripeClient.new) @external_client = external_client end def method_missing(*args, &block) begin external_client.send(*args, &block) rescue => e raise PaymentGateway::ClientError.new(e.message) end end
end
All of our payment gateway services will inherit from this class. Why? Because it defines the client which will be used in all of our payment gateway services.
app/services/payment_gateway/service.rb
class PaymentGateway::Service protected def client @client ||= PaymentGateway::Client.new end
end
Testeable code rocks that's why we will build a service to delegate the subscription creation. As you can see our service doesn't know anything about Stripe. It just works!
app/services/payment_gateway/create_subscription_service.rb
class PaymentGateway::CreateSubscriptionService < Service ERROR_MESSAGE = "There was an error while creating the subscription" attr_accessor :user, :plan, :token, :subscription, :success def initialize(user:, plan:, token:) @user = user @plan = plan @token = token @successs = false end def run begin Subscription.transaction do create_client_subscription self.subscription = create_subscription self.success = true end rescue PaymentGateway::CreateCustomerService, PaymentGateway::CreatePlanService, PaymentGateway::ClientError => e raise PaymentGateway::CreateSubscriptionServiceError.new( ERROR_MESSAGE, exception_message: e.message) end end private def create_client_subscription client.create_subscription!( customer: payment_gateway_customer, plan: paymeny_gateway_plan, token: token) end private def create_subscription Subscription.create!(user: user, plan: plan, start_date: Time.zone.now.to_date, end_date: plan.end_date_from, status: :active) end private def payment_gateway_customer create_customer_service = PaymentGateway::CreateCustomerService.new( user: user) create_customer_service.run end private def paymeny_gateway_plan get_plan_service = PaymentGateway::GetPlanService.new( plan: plan) get_plan_service.run end
end
Again, we create another service to delegate the customer creation.
app/services/payment_gateway/create_customer_service.rb
class PaymentGateway::CreateCustomerService < Service EXCEPTION_MESSAGE = "There was an error while creating the customer" attr_accessor :user def initialize(user: ) @user = user end def run begin User.transaction do client.create_customer!(email: user.email).tap do |customer| user.update!(payment_gateway_customer_identifier: customer.id) end end rescue ActiveRecord::RecordInvalid, PaymentGateway::ClientError => e raise PaymentGateway::CreateCustomerService.new( EXCEPTION_MESSAGE, exception_message: e.message) end end
end
We'll delegate the Stripe plan creation to the CreatePlanService
app/services/create_plan_service.rb
class PaymentGateway::CreatePlanService < Service EXCEPTION_MESSAGE = "There was an error while creating the plan" attr_accessor :payment_gateway_plan_identifier, :name, :price_cents, :interval def initialize(payment_gateway_plan_identifier:, name:, price_cents:, interval:) @payment_gateway_plan_identifier = payment_gateway_plan_identifier @name = name @price_cents = price_cents @interval = interval end def run begin Plan.transaction do create_client_plan create_plan end rescue ActiveRecord::RecordInvalid, PaymentGateway::ClientError => e raise PaymentGateway::CreatePlanServiceError.new(EXCEPTION_MESSAGE, exception_message: e.message) end end private def create_client_plan client.create_plan!( name, id: payment_gateway_plan_identifier, amount: price_cents, currency: "usd", interval: interval) end private def create_plan Plan.create!( payment_gateway_plan_identifier: payment_gateway_plan.id, name: name, price_cents: price_cents, interval: interval, status: :active) end
end
Let's create the ServiceError
class and it's childrens, which will help us to handle OUR OWN application exceptions, it is a good idea to raise our own exceptions, here is why: Imagine you want to switch to another platform like Braintree... without this implementation you will end up findining the places where you rescue Stripe exceptions and change them to Braintree exceptions (not so cool). This approach will facilitate our lifes since we will not need to take care about changing library specific errors all over our code. (We will talk more about this later).
app/services/service_error.rb
class PaymentGateway::ServiceError < StandardError attr_reader :exception_message def initialize(message, exception_message: ) # Call the parent's constructor to set the message super(message) # Store the exception_message in an instance variable @exception_message = exception_message end
end class PaymentGateway::CreateSubscriptionServiceError < PaymentGateway::ServiceError
end class PaymentGateway::CreatePlanServiceError < PaymentGateway::ServiceError
end class PaymentGateway::CreateCustomerServiceError < PaymentGateway::ServiceError
end class PaymentGateway::StripeClientError < PaymentGateway::ServiceError
end
lib/tasks/plans.rake
namespace :plans do task create: :environment do plans = [ {payment_gateway_plan_identifier: "gold", name: "Gold", price_cents: 20_000, interval: "monthly"}, {payment_gateway_plan_identifier: "silver", name: "Silver", price_cents: 10_000, interval: "monthly"} ] Plan.transaction do begin plans.each do |plan| PaymentGateway::CreatePlanService.new(**plan).run end rescue PaymentGateway::CreatePlanServiceError => e puts "Error message: #{e.message}" puts "Exception message: #{e.exception_message}" end end end
end
We'll setup Stripe webhooks to listen for subscriptions changes, this will allow us to register/track subscriptions changes locally in our application. For example, you can send emails or create online notifications or anything similar to inform a user about subscription changes.
We'll use the StripeEvent gem it will allow us to receive Stripe events in our application.
config/routes.rb
Rails.application.routes.draw do root to: "pages#index" devise_for :users resources :plans do resources :subscriptions end mount StripeEvent::Engine, at: '/stripe_events'
end
source "https://rubygems.org"
ruby "2.5.1" gem "rails", "~> 5.2.0"
gem "devise"
gem "jquery-rails"
gem "money-rails"
gem "stripe"
...
Registering events locally is a great idea, reasons: First, because requesting info to an external API is slow. Second, in case you want to do analytics with the data this will help you a lot. Third, you can customize the data. For now let's create a simple event model it will save all the event payload in a JSONB column.
rails generate model event payment_gateway_event_data:jsonb
Setting up this gem is pretty straightforward, we will tell StripeEvent which events are of our interests, for now we will only handle one event (invoice payment failed), but you can handle ALL of them if you want.
config/initializers/stripe.rb
Stripe.api_key = ENV['STRIPE_SECRET_KEY']
StripeEvent.signing_secret = ENV['STRIPE_SIGNING_SECRET'] StripeEvent.configure do |events| events.subscribe( 'invoice.payment_failed', PaymentGateway::Events::InvoicePaymentFailed.new)
end
This class returns the Stripe event, we are paranoid that's why we want to verify the event from Stripe.
app/services/payment_gateway/get_plan_service.rb
class PaymentGateway::GetPlanService < Service attr_accessor :payment_gateway_event_identifier def initialize(payment_gateway_event_identifier: ) @payment_gateway_event_identifier = payment_gateway_event_identifier end def run begin get_client_event rescue PaymentGateway::ClientError => e raise CreatePlanServiceError.new("There was an error while retreiving the event", exception_message: e.message) end end private def get_client_event client.lookup_plan(identifier: payment_gateway_event_identifier) end
end
Our invoice payment failed class will handle the Stripe event, we'll build the class in such way that is it going to create the event locally. But you can do a lot here... For instance you can send emails, broadcast an action cable channel, or anything like that.
This class creates an event locally using the webhook information AND the verified information, we are paranoid that's why we want to verify the event from Stripe.
app/services/payment_gateway/events/invoice_payment_failed.rb
class PaymentGateway::Events::InvoicePaymentFailed def call(payment_gateway_event) create_event(verified_payment_gateway_event(payment_gateway_event)) end private create_event(event) Event.create!(JSON.parse(event.to_json)) end private get_payment_gateway_event(event) get_plan_service = PaymentGateway::GetPlanService.new(event.id) get_plan_service.run end
end
Here is a list of really important things to keep in mind while implementing webhooks:
We have learned how to implement subscriptions with Stripe, we also learned how to design elegant services classes, we finally learned how to implement Stripe webhooks.
Make sure to let your users know that you are not storing credit card information in your systems. Automated testing is really important since you are dealing with real money, but I'm not covering that in this post since it's a large subject. Webhook testing can be done with ngrok.
As you can see implementing subscriptions with Stripe is pretty simple, the documentation is extremily detailed Stripe API Reference make sure to take a look whenever you need to look example responses.