Stripe is a payment processing platform that offers a lot of great features. You can accept online payments, create subscriptions, receive payouts, etc.

In this article, I'm going to develop a subscription feature with different plans. After a user is authenticated, he/she will be able to choose a plan and pay it.

Creating an Account on Stripe

First we need to create an account on Stripe to get the publishable_key and secret_key. These keys will be used on our server-side application to communicate with Stripe. Fortunately Stripe offers testing keys that you can use without any charges. When your app is ready, you can verify your account and get keys for production.

After signing up on Stripe, next step is to create our monthly products:

Stripe Products Section

We have created three products(Premium, Pro, and Basic) that the users can select and be billed monthly.

Developing Our Application

Next let's generate a Rails application and add the gems like Stripe, and Devise then install Stimulus.We are going to develop a simple web application where the user after is authenticated, will be sent to plans page and pay for it.

Setting Up The Application

Generate a new Rails application with rails new command:

 rails new stripe_subscription -T  database=posgresql

After moving inside project’s directory, install Stimulus.js:

bin/rails webpacker:install:stimulus

Add the necessary gems like stripe ,devise on the Gemfile and run:       bundle install.

...
gem 'stripe', '~> 5.34'
gem 'devise', '~> 4.8'
...

Install devise:  rails generate devise:install.

Add Stripe.js library on application.html.erb file:

<!DOCTYPE html>
<html>
  <head>
    <title>StripeSubscription</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
    <%# Stripe v3 %>
    <script src="https://js.stripe.com/v3/"></script>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

We will use Stripe.js to ensure card details are sent to Stripe without hitting our server. We have to follow this path in order to remain PCI compliant. This will also allow us to access Stripe JavaScript API-s on the client side of the application.

Finally create the stripe.rb inside confing/initializers folder on our application:

Inside this file we have assigned our secret_key that will allow us to access Stripe API-s from our server side of the application. These keys have a lot of privileges, so make sure to store them securely. You can also set a per-request key by using api_key option on Stripe method calls.

Creating The User

Next we are going to generate a User model that will be used as the customer who is going to authenticate before accessing the plans. Thankfully devise got us covered, we can use devise generator to generate a model that will be configured with the default Devise modules.

rails generate devise User customer_id price_id subscription_id

Each user will have a  customer_id that is created by Stripe. The price_id is the id of the plan that the user is subscribed to. This will be used in the future to provision the access on the features of our application(ex. a premium user can have unlimited access, while a basic user can access 5 features only). subscription_id is the id of the subscription created on Stripe after the user has purchased one of the plans.

Finally run the migration: rails db:migrate

Next we are going to add a before_create callback to the user model:

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
        :recoverable, :rememberable, :validatable

  before_create :create_stripe_customer


  private
  
  def create_stripe_customer
    customer = Stripe::Customer.create({
      email: self.email
    })
    self.customer_id = customer.id
  end
end

This will make sure that, before the user is created a new customer is going to be created as well on the Stripe platform.

Defining the routes

Rails.application.routes.draw do
  root to: 'static_pages#index'
  devise_for :users
  resources :plans, only: [:index, :create] do 
    member do 
      get 'checkout'
    end
  end
end

Above we have defined the root route which points to the index action of StaticPagesController. Devise adds a RESTful route named after the model we generated, in this case :users. And finally we define :index, :create and :checkout for PlansController.

Creating The Plans

Next let's define a controller which is going to return the plans page to the user after authentication.

class PlansController < ApplicationController
  def index
    @prices = Stripe::Price.list({active: true, expand: ['data.product']})
  end
end

When the user hits the index route of PlansController we are going to get the prices from Stripe and pass it to the index template. Calling Stripe::Price.list() returns back a list of Price objects created on the Stripe platform. We also passed two parameters active to make sure archived prices are filtered out, and expand allows us to further expand Price objects on other resources. So price has a product id and we can use it to expand and get the full Product object.

<div class="main">
  <h2 style="text-align:center">Pricing</h2>
  <p style="text-align:center">Please select your package</p>

  <%= form_tag plans_path, method: :post do %>
    <%= hidden_field_tag "selected_price" %>
    
    <% @prices.each do |price| %>
      <div class="columns">
        <ul class="price">
          <li class="header"> <%= price.product.name %> </li>
          <li class="grey"><%= number_to_currency(price.unit_amount / 100, unit: "€ ") %> / month </li>
          <li>Your text here</li>
          <li>Your text here</li>
          <li>Your text here</li>
          <li>Your text here</li>
          <li class="grey"><a href="#" class="button" id="<%= price.id%>">Sign Up</a>
          </li>
        </ul>
      </div>
    <% end %>
  <% end %>
</div>

The first div tag serves as the root element of the entire index template. We have also added the data-controller="plans". This attribute will connect our template with an instance of our javascript Stimulus class controller(which we'll see next). In this case we used the value "plans" to connect an instance of plans_controller.js class.

Next we have defined a form_tag that encapsulates the prices rendered. We also have a hidden_field_tag that will hold the id of the selected price by the user. This field have a data-target attribute defined, this will tell Stimulus that this element is of significance and you should hold a reference to it. The submit button has the id which hold the id of the price. Another attribute assigned to button is data-action="click->plans#handleSubmit" , this is another of great features of Stimulus. Basically data-action attribute connects controller methods to DOM events. In our case we have a click event and we want to execute handleSubmit ,which is defined inside plans_controller.js.

Let's check our plans_controller.js:

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "form", "selectedPrice" ]

  handleSubmit(e){
    e.preventDefault();
    this.selectedPriceTarget.value = e.target.dataset.id;
    this.formTarget.submit();
  }
}

This is a simple javascript class that extends Controller class of Stimulus. We have also defined the targets. Next we have a function which handles the click event of pricing buttons. handleSubmit() assigns the price id to the hidden field selectedPrice we defined earlier on our template and then triggers the submit of the form. This will send the form data to create action of PlansController.

After adding the design visit http://localhost:3000/plans:

plans page

Creating The Subscription

Now that the user has selected one of the plans, we need to create a subscription marked unpaid for now on create action of PlansController:

  def create
    subscription = Stripe::Subscription.create({
      customer: current_user.customer_id,
      items: [
        price: params[:price_id]
      ], 
      payment_behaviour: 'default_incomplete',
      expand: ['latest_invoice.payment_intent']    
    })

    current_user.subscription_id = subscription.id
    current_user.save!

    redirect_to checkout_path(subscription.latest_invoice.payment_intent.client_secret)
  end

First we create a subscription for the user with Stripe::Subscription.create() and we pass a couple of parameters. customer parameter is the current_user's customer id, next is the price id passed inside items array, we have passed payment_behaviour: 'default_incomplete' to mark this subscription as incomplete since the user hasn't paid it yet. Finally we send the user to the checkout page passing the client_secret as its parameter. The client_secret will be used on the frontend to process the user payment to the Stripe servers.

Next we have the checkout action defined inside PlansController:


  def checkout
    @client_secret = params[:id]
  end

We have created an instance variable of the client_secret in order to pass it our checkout.html.erb template.

Next we have the checkout page:

<div class="main" data-controller="checkout">
  <h2 style="text-align:center">Checkout</h2>
  <p style="text-align:center">Please enter your card details</p>

  <%= form_tag root_path, method: :post, data: {target:"checkout.form"}, class: "card center", style: "width: 33%" do %>
    <%= hidden_field_tag "client_secret", @client_secret, data: {target: "checkout.clientSecret"} %>
    <div>
      <%= label_tag "email" %>
      <%= email_field_tag "email",nil, class: "input" %>
    <div>
    <div id="card-element" data-target="checkout.cardElement">
    </div>
    <div id="card-element-errors" role="alert"></div>
    <div>
      <button class="button" data-action="click->checkout#handleCheckout">
        Subscribe
      </button>
    </div>
  <% end %>
</div>

We use the same technique here  where we have connected another Stimulus controller named checkout_controller.js. Then a  form that points to root_path of our application. Inside the form we have a hidden_field_tag that holds the client_secret, email_field_tag with a label for the user to enter his email. The next important element is the div with id card-element this will be the container where the Stripe will inject its card input field with expiration date, cvc and Zip. Next is the div with id car-element-errors for displaying any errors that might occur, and finally the submit button.

After adding the design: http://localhost:3000/plans/pi_1J56giItGR9LPQvQGZPv4YIg_secret_s8YJdyQcwq4EfaTXIunFAxAdl/checkout :

Below I have added the checkout Stimulus controller checkout_controller.js:

export default class extends Controller {
  static targets = ['form', 'clientSecret','email','cardElement','cardError']

  initialize(){
    // bindings of `this` to the functions
    this._handleCardError = this._handleCardError.bind(this);
    this.stripe = Stripe("pk_test_51J3HG4ItGR9LPQvQKQlFXkTuOYuWxAvD1FVqdnbXs5RzhEH1900r4QlYa1qxzB8I5GqOMaVDPbqavgRbhe2rwDRC00Y9Zakjcs");
    this.elements = this.stripe.elements();
    this.card = this.elements.create('card', {
      style:
      {
        base: {
          lineHeight: '1.429'
        }
      },
      classes: {
        base: 'input'
      }
    });
    this.card.mount(`#${this.cardElementTarget.getAttribute("id")}`);
    this.card.on("change", this._handleCardError);
  }

  handleCheckout(e) {
    e.preventDefault();
    this.stripe.confirmCardPayment(this.clientSecretTarget.value, {
      payment_method: {
        card: this.card,
        billing_details: {
          email: this.emailTarget.value
        }
      }
    }).then(result => {
      if(result.error){
        alert(result.error.message);
      } else {
        alert("Payment processed successfuly");
        this._disableFormElements();
        this.formTarget.method = "GET";
        this.formTarget.submit();
      }
    }).catch(err => {
        if (err){
          alert(err.message);
        }
      })
  }

  _handleCardError(event){
    if(event.error){
      this.cardErrorTarget.textContent = event.error.message;
    } else {
      this.cardErrorTarget.textContent = '';
    }
  }
  _disableFormElements(){
    Array.from(this.formTarget.elements).forEach(formElement => formElement.disabled = true);
  }
}

First we have defined our targets: form, clientSecret, email , cardElement , and cardError.

initialize() is a lifecycle callback function, and is the first to be called after the controller is instantiated. There are two other lifecycle functions in Stimulus: connect() which is called when the controller is connected to the DOM, and disconnect() when is disconnected from the DOM

Inside initialize()  first we bind this keyword to the function _handleCardError() in order to access this in the context of the class and not the global one or in some cases undefined.

Next we create an instance of Stripe passing our public key as an argument. Then we call this.stripe.elements() which creates an instance of Elements . Next we use elements instance to create a specific type of element in our case a card element.

Finally we mount the card element to our div with id card-element located inside checkout form template.

handleCheckout() is a click handler for the button of the checkout form, inside we call a function this.stripe.confirmCardPayment() which does an API call to the Stripe server sending our form's data. This function accepts three parameters: a client_secret, data, options and returns back a promise. If we have errors show the error else disable all fields of the form, and submit it. Since the form is set to the root_path it is going to redirect to our app's root path.

Conclusion

There are many ways we could implement this feature, and also there are many things we could add like check if user is already subscribed, implement a billing page showing all the details, cancel the plan or upgrade the plan.

You can find the source code HERE

References

Documentation
Explore our guides and examples to integrate Stripe.
Official Documentation of Stripe
Stripe JavaScript SDK reference
Complete reference documentation for the Stripe JavaScript SDK.
Stripe Javascript Documentation
Stripe API reference
Complete reference documentation for the Stripe API. Includes code snippets and examples for our Python, Java, PHP, Node.js, Go, Ruby, and .NET libraries.
Stripe Server Side Documentation
Stimulus: A modest JavaScript framework for the HTML you already have.
Stimulus is a JavaScript framework with modest ambitions. It doesn’t seek to take over your entire front-end—in fact, it’s not concerned with rendering HTML at all. Instead, it’s designed to augment your HTML with just enough behavior to make it shine.
Stimulus.js Documentation
Create subscriptions with Elements
Learn how to offer multiple pricing options to your customers and charge them a fixed amount each month.
Official Stripe Subscription with Elements