Subscriptions with Stripe

Why build this yourself

When it comes to building a subscription flow with Stripe in a Rails app there are a few options:

If the above options don't appeal you'll want to build it yourself. This will give you all the benefits of DIY:

What you'll build

This app demonstrates how to build a bespoke Stripe subscription flow in Rails, broadly speaking we follow the Stripe Subscriptions Integration Guide. We start with a dynamic pricing page where you can toggle between monthly and yearly billing and choose a price to proceed.

We then have a Devise signup page where you can enter your business/account name and user email address. At this point a User, Business and Subscription object are created along with a Stripe customer object and an incomplete Stripe subscription object. The user is also signed in.

Then we create a payment page where you can enter your card details using Stripe Payment Element, Stripe's latest and greatest web component.

Finally we show the logged in and card entered user a subscription page where they can see their current subscription status and manage their subscription using the Stripe Billing Portal. We stay up to date with missing payments or cancellations by using webhoooks.

Key features:

App setup

We start with the pattern_base1 base app which can also be created with the following command:

rails new $APP_NAME -d postgresql --skip-jbuilder --skip-test

Then we add and configure the following gems to the Gemfile:

bundle add {stripe,stripe_event,money-rails}
rails g money_rails:initializer

Stripe setup

First open your credential file with rails credentials:edit then visit the Stripe Test Developer Dashboard and copy and paste the Publishable key and Secret key from the API tab under the development: key in the credential file. We'll add the webhook signing secret and production keys later.

Alternatively you can use the poorly documented multi-environment credentials and create separate credential files for each environment.

Then still in the Stripe Test dashboard you'll need to create the product and prices. Think of the product as a bucket for the prices. Most SaaS products will only have one, where a DTC brand might have many.

You can then create multiple prices for each product which might reflect Pro vs Standard. In this example we actually have 3 variables for each price, creating 8 prices in total:

We also add trial periods (in days) here. Here we set the trial period to 0 days (no trial) for all prices but you can override trial periods for specific prices or plans. Here for example we set different trial periods for different prices and plans, more specific overrides take precedence over less specific ones:

trial_periods:
  standard_monthly_uk: 10
  standard_monthly: 15
  standard: 30
  all: 0

Below you can see the full expansion of the prices. Once you've created them all you can copy and paste the price IDs into your credential file:

development: &development
  stripe:
    publishable_key: pk_test_xxx
    secret_key: sk_test_xxx
    signing_secret: whsec_xxx
    prices:
      standard_monthly_uk: price_xxx
      standard_monthly_us: price_xxx
      standard_yearly_uk: price_xxx
      standard_yearly_us: price_xxx
      pro_monthly_uk: price_xxx
      pro_monthly_us: price_xxx
      pro_yearly_uk: price_xxx
      pro_yearly_us: price_xxx
    trial_periods:
      all: 0

test:
  <<: *development

production:
  stripe:
    publishable_key: pk_live_xxx
    secret_key: sk_live_xxx
    signing_secret: whsec_xxx
    prices:
      standard_monthly_uk: price_xxx
      standard_monthly_us: price_xxx
      standard_yearly_uk: price_xxx
      standard_yearly_us: price_xxx
      pro_monthly_uk: price_xxx
      pro_monthly_us: price_xxx
      pro_yearly_uk: price_xxx
      pro_yearly_us: price_xxx
    trial_periods:
      all: 0

Then create your Stripe initializer at config/initializers/stripe.rb and add the following code:

Rails.configuration.stripe = Rails.application.credentials.dig(Rails.env.to_sym, :stripe)
Stripe.api_key = Rails.configuration.stripe.secret_key

This lets you access the Stripe credentials and prices in your app like so:

Rails.configuration.stripe.publishable_key

Rails.configuration.stripe.prices.fetch(:standard_monthly_uk)

Domain modeling

Price

Firstly lets create a Price class to allow us to easily fetch price information in our app:

class Price
  include ActiveModel::API
  include ActiveModel::Attributes

  attribute :plan, :string
  attribute :period, :string
  attribute :location, :string

  CACHED_PRICES = {
    standard_monthly_uk: Money.new(89_00, :GBP),
    standard_monthly_us: Money.new(99_00, :USD),
    standard_yearly_uk: Money.new(899_00, :GBP),
    standard_yearly_us: Money.new(999_00, :USD),
    pro_monthly_uk: Money.new(199_00, :GBP),
    pro_monthly_us: Money.new(249_00, :USD),
    pro_yearly_uk: Money.new(1_999_00, :GBP),
    pro_yearly_us: Money.new(2_499_00, :USD)
  }.freeze

  def initialize(**args)
    raise ArgumentError, "Must provide either a full_plan_id or plan, period and location" unless args.key?(:full_plan_id) || (args.key?(:plan) && args.key?(:period) && args.key?(:location))

    return super unless args.key?(:full_plan_id)

    args[:full_plan_id].to_s.split("_") => [plan, period, location]

    super(plan:, period:, location:)
  end

  def stripe_price_id
    Rails.application.credentials.dig(Rails.env.to_sym, :stripe, :prices, full_plan_id.to_sym)
  end

  def full_plan_id
    [plan, period, location].join("_")
  end

  def cached_price
    CACHED_PRICES[full_plan_id.to_sym]
  end

  def raw_stripe_price
    @raw_stripe_price ||= Stripe::Price.retrieve(id: stripe_price_id)
  end

  def stripe_price
    Money.new(raw_stripe_price.unit_amount, raw_stripe_price.currency)
  end

  def to_s
    cached_price.format(no_cents_if_whole: true)
  end

  def trial_period_days
    trial_periods = Rails.application.credentials.dig(Rails.env.to_sym, :stripe, :trial_periods) || {}
    trial_periods.dig("#{plan}_#{period}_#{location}".to_sym) ||
    trial_periods.dig("#{plan}_#{period}".to_sym) ||
    trial_periods.dig(plan.to_sym) ||
    trial_periods.dig(:all) ||
    0
  end

  def trial?
    trial_period_days.positive?
  end
end

We can create a price with price = Price.new(plan:, period:, location:) or price = Price.new(full_plan_id: :standard_monthly_uk), then either fetch live price information from Stripe with price.stripe_price or fetch the cached version with price.cached_price.

We can fetch the trial period in days with price.trial_period_days where you can see how the specificity works, and we can check if there is a trial with price.trial?.

Subscription

We create a Subscription model class to store Stripe subscription info and extract some of the complexity of the Stripe API and the subscription specific data out of our main app domain models.

Another benefit of a separate Subscription object is that you can take payment and save the object before capturing the customer's details. In this example we do that afterwards but it wouldn't be difficult to change.

We're assuming you use a model named Business to represent your customer but you could also use Account or Team or even User depending on your app. Most B2B SaaS apps will have a Business model with a separate User model for authentication.

rails g model Business name:string # Assuming you don't already have a customer model in your app

rails g model Subscription \
  business:references \
  stripe_id:string \
  stripe_customer_id:string \
  stripe_price_id:string \
  full_plan_id:string \
  status:string \
  cancel_at_period_end:boolean \
  current_period_starts_at:datetime \
  current_period_ends_at:datetime \
  trial_ends_at:datetime

rails db:setup # Assuming you haven't already created your database
rails db:migrate

Now let's add some code to the Subscription model:

# app/models/subscription.rb

class Subscription < ApplicationRecord
  ACCESS_GRANTING_STATUSES = ['trialing', 'active', 'past_due'].freeze

  belongs_to :business

  enum :status, {
    trialing: 'trialing',
    incomplete: 'incomplete',
    active: 'active',
    past_due: 'past_due',
    canceled: 'canceled',
  }

  validates :stripe_id, presence: true
  validates :stripe_customer_id, presence: true
  validates :stripe_price_id, presence: true
  validates :full_plan_id, presence: true

  attr_accessor :plan, :period, :location

  before_validation :create_stripe_objects, on: :create

  def create_stripe_objects
    self.full_plan_id = price.full_plan_id
    self.stripe_price_id = price.stripe_price_id
    create_stripe_customer
    create_stripe_subscription
  end

  def create_stripe_customer
    return if stripe_customer_id.present?

    self.stripe_customer_id = Stripe::Customer.create(business.customer_attributes_for_stripe).id
  end

  def create_stripe_subscription
    return if stripe_id.present?

    subscription_obj = Stripe::Subscription.create(
      customer: stripe_customer_id,
      items: [{ price: price.stripe_price_id }],
      payment_behavior: "default_incomplete",
      payment_settings: { save_default_payment_method: "on_subscription" },
      expand: [price.trial? ? "pending_setup_intent" : "latest_invoice.payment_intent"],
      trial_period_days: price.trial_period_days
    )

    self.stripe_id = subscription_obj.id

    assign_stripe_attrs(subscription_obj)
  end

  def update_status!
    update!(status: stripe_subscription.status)
  end

  def self.update_from_stripe_subscription(subscription_obj)
    subscription = find_by(stripe_id: subscription_obj.id)
    return unless subscription
    subscription.assign_stripe_attrs(subscription_obj)
    subscription.save!
  end

  def assign_stripe_attrs(subscription_obj)
    assign_attributes(
      status: subscription_obj.status,
      default_payment_method: subscription_obj.default_payment_method || (price.trial? ? subscription_obj.pending_setup_intent.payment_method : subscription_obj.latest_invoice.payment_intent.payment_method),
      cancel_at_period_end: subscription_obj.cancel_at_period_end,
      current_period_starts_at: Time.at(subscription_obj.current_period_start),
      current_period_ends_at: Time.at(subscription_obj.current_period_end),
      trial_ends_at: subscription_obj.trial_end && Time.zone.at(subscription_obj.trial_end)
    )
  end

  def handle_creation!
    assign_stripe_attrs(stripe_subscription)
    save!
  end

  def stripe_subscription
    @stripe_subscription ||= Stripe::Subscription.retrieve(id: stripe_id, expand: [price.trial? ? "pending_setup_intent" : "latest_invoice.payment_intent"])
  end

  def stripe_customer
    @stripe_customer ||= Stripe::Customer.retrieve(id: stripe_customer_id)
  end

  def customer_portal_session_url(return_url)
    @customer_portal_session_url ||= Stripe::BillingPortal::Session.create(customer: stripe_customer_id, return_url:)["url"]
  end

  def price
    @price ||= full_plan_id.present? ? Price.new(full_plan_id:) : Price.new(plan:, period:, location:)
  end

  def payment_intent
    price.trial? ? stripe_subscription.pending_setup_intent : stripe_subscription.latest_invoice.payment_intent
  end

  def client_secret
    payment_intent.client_secret
  end

  def next_billing_date
    Time.at(stripe_subscription.current_period_end).to_date
  end

  def in_trial_period?
    trial_ends_at.present? && trial_ends_at > Time.zone.now
  end

  def active_or_trialing?
    ACCESS_GRANTING_STATUSES.include?(status) && default_payment_method.present?
  end
end

A few points to note:

Business

Now we can add some methods to our Business model. We automatically create a Subscription object when a Business is created. The plan attributes are passed in from the controller then passed through to the Subscription model.

class Business < ApplicationRecord
  has_one :subscription

  after_create :create_subscription_object

  attr_accessor :plan, :period, :location

  private

  def create_subscription_object
    create_subscription(plan:, period:, location:)
  end

  def customer_attributes_for_stripe
    { name: } # Can also add address, email and metadata
  end
end

Signup and subscription flow

Our signup and subscription flow will look something like this:

  1. User visits the pricing page and clicks on a plan
  2. User is sent to the devise signup page with the plan attributes in the params
  3. User signs up, at which point the Business and Subscription objects are created along with associated Stripe customer object and incomplete Stripe subscription object
  4. User is sent to the payment page (/subscriptions/new) where they enter their card details and submit the form
  5. The form handler sends the card details directly to Stripe which then redirects to a url in the app that we specify
  6. We specify a page that shows the user their subscription information and allows them to access the stripe billing portal
  7. If there is an error in the card details that is dealt with by the form handler and the redirect doesn't happen

Pricing page

First we'll create a basic pricing page using Tailwind and Stimulus:

<!-- app/views/pages/pricing.html.erb -->

<div
  data-controller="pricing"
  data-pricing-on-class="bg-indigo-600 text-white"
  data-pricing-off-class="text-gray-500"
  data-pricing-open-value="true"
  data-pricing-prices-value="<%= Price::PRICES.to_json %>"
>
  <div class="mx-auto max-w-7xl px-6 lg:px-8">
    <div class="mt-16 flex justify-center">
      <fieldset data-action="click->pricing#toggle touch->pricing#toggle" class="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold leading-5 ring-1 ring-inset ring-gray-200">
        <legend class="sr-only">Payment frequency</legend>
        <label data-pricing-target="on" class="cursor-pointer rounded-full px-2.5 py-1 bg-indigo-600 text-white">
          <input type="radio" name="period" value="monthly" class="sr-only">
          <span>Monthly</span>
        </label>
        <label data-pricing-target="off" class="cursor-pointer rounded-full px-2.5 py-1 text-gray-500">
          <input type="radio" name="period" value="annually" class="sr-only">
          <span>Annually</span>
        </label>
      </fieldset>
    </div>

    <div class="isolate mx-auto mt-10 grid max-w-md grid-cols-1 gap-8 lg:mx-0 lg:max-w-none lg:grid-cols-2">
      <div class="rounded-3xl p-8 xl:p-10 ring-1 ring-gray-200">
        <div class="flex items-center justify-between gap-x-4">
          <h3 id="tier-freelancer" class="text-lg font-semibold leading-8 text-gray-900">Standard</h3>
        </div>
        <p class="mt-4 text-sm leading-6 text-gray-600">Plan description...</p>
        <p class="mt-6 flex items-baseline gap-x-1">
          <span data-pricing-target='toggleable' class="text-4xl font-bold tracking-tight text-gray-900"><%= Price.new(plan: :standard, period: :monthly, location: :uk) %></span>
          <span data-pricing-target='toggleable' class="text-sm font-semibold leading-6 text-gray-600">/month</span>
          <span data-pricing-target='toggleable' class="text-4xl font-bold tracking-tight text-gray-900 hidden"><%= Price.new(plan: :standard, period: :yearly, location: :uk) %></span>
          <span data-pricing-target='toggleable' class="text-sm font-semibold leading-6 text-gray-600 hidden">/year</span>
        </p>

        <%= form_tag new_user_registration_path, method: :get do %>
          <%= hidden_field_tag :location, 'uk' %>
          <%= hidden_field_tag :plan, 'standard' %>
          <%= hidden_field_tag :period, 'monthly', data: { 'pricing-target': 'period' } %>
          <%= button_tag "Subscribe", name: nil, class: "mt-6 block w-full rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 text-indigo-600 ring-1 ring-inset ring-indigo-200 hover:ring-indigo-300" %>
        <% end %>
      </div>

      <div class="rounded-3xl p-8 xl:p-10 ring-2 ring-indigo-600">
        <div class="flex items-center justify-between gap-x-4">
          <h3 id="tier-startup" class="text-lg font-semibold leading-8 text-indigo-600">Pro</h3>
        </div>
        <p class="mt-4 text-sm leading-6 text-gray-600">Plan description...</p>
        <p class="mt-6 flex items-baseline gap-x-1">
          <span data-pricing-target='toggleable' class="text-4xl font-bold tracking-tight text-gray-900"><%= Price.new(plan: :pro, period: :monthly, location: :uk) %></span>
          <span data-pricing-target='toggleable' class="text-sm font-semibold leading-6 text-gray-600">/month</span>
          <span data-pricing-target='toggleable' class="text-4xl font-bold tracking-tight text-gray-900 hidden"><%= Price.new(plan: :pro, period: :yearly, location: :uk) %></span>
          <span data-pricing-target='toggleable' class="text-sm font-semibold leading-6 text-gray-600 hidden">/year</span>
        </p>

        <%= form_tag new_user_registration_path, method: :get do %>
          <%= hidden_field_tag :location, 'uk' %>
          <%= hidden_field_tag :plan, 'pro' %>
          <%= hidden_field_tag :period, 'monthly', data: { 'pricing-target': 'period' } %>
          <%= button_tag "Subscribe", name: nil, class: "mt-6 block w-full rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 bg-indigo-600 text-white shadow-sm hover:bg-indigo-500" %>
        <% end %>
      </div>
    </div>
  </div>
</div>

We can then generate a stimulus controller for the pricing page with rails g stimulus pricing and amend it as follows:

// app/javascript/controllers/pricing_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["toggleable", "on", "off", "period"];
  static classes = ["on", "off"];
  static values = { open: Boolean, prices: Object };

  connect() {
    this.toggleClass = this.data.get("class") || "hidden";
  }

  toggle(event) {
    event.preventDefault();
    this.openValue = !this.openValue;
  }

  openValueChanged() {
    if (this.openValue) {
      this.periodTargets.forEach((target) => (target.value = "monthly"));
      this.onTarget.classList.remove(...this.offClasses);
      this.onTarget.classList.add(...this.onClasses);
      this.offTarget.classList.remove(...this.onClasses);
      this.offTarget.classList.add(...this.offClasses);
    } else {
      this.periodTargets.forEach((target) => (target.value = "yearly"));
      this.onTarget.classList.remove(...this.onClasses);
      this.onTarget.classList.add(...this.offClasses);
      this.offTarget.classList.remove(...this.offClasses);
      this.offTarget.classList.add(...this.onClasses);
    }

    this.toggleableTargets.forEach((target) => {
      target.classList.toggle(this.toggleClass);
    });
  }
}

These are both pretty basic. The controller toggles the plan period between monthly and yearly, shows the correct price and changes the value of the hidden period field in the form.

You'll note we also provide the prices into the Stimulus controller as JSON in the data-pricing-prices-value attribute. We dont use them but here but they could be useful if you want to develop the pricing page further.

One nice thing about this approach is that the pricing page doesnt need the price ids, just the plan attributes. This makes it easier to maintain the pricing page along with the website in a different app if necessary.

Signup page

First let's install devise, generate a User model, and link it to our Business model:

bundle add devise
rails g devise:install
rails g devise User
rails g migration AddBusinessToUsers business:belongs_to
rails db:migrate

Link them together by adding the following to app/models/user.rb, accepts_nested_attributes_for allows us to sign up with business attributes:

class User < ApplicationRecord
  belongs_to :business
  accepts_nested_attributes_for :business
end

and the following to app/models/business.rb:

class Business < ApplicationRecord
  has_many :users
end

Now we'll create a custom view for the signup screen which includes the attributes for the business we want to create at signup:

<!-- app/views/devise/registrations/new.html.erb -->

<div class="m-4">
  <h2 class="font-bold text-lg">Sign up</h2>

  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }, html: { class: 'form' }) do |f| %>
    <%= render "devise/shared/error_messages", resource: resource %>

    <%= f.fields_for :business do |bf| %>
      <%= bf.hidden_field :plan %>
      <%= bf.hidden_field :period %>
      <%= bf.hidden_field :location %>

      <div class="mt-2">
        <%= bf.label :name, "Business name" %><br />
        <%= bf.text_field :name %>
      </div>
    <% end %>

    <div class="mt-2">
      <%= f.label :email %><br />
      <%= f.email_field :email %>
    </div>

    <div class="mt-2">
      <%= f.label :password %>

      <% if @minimum_password_length %>
        <em>(<%= @minimum_password_length %> characters minimum)</em>
      <% end %>
      <br />

      <%= f.password_field :password, autocomplete: "new-password" %>
    </div>

    <div class="mt-2">
      <%= f.label :password_confirmation %><br />
      <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
    </div>

    <div class="mt-4">
      <%= f.submit "Sign up", class: "cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
    </div>
  <% end %>
</div>

And we'll need to customise the devise controller as well. We first copy it into our app with:

rails generate devise:controllers users -c registrations

Then put it to use by modifying the routes file at config/routes.rb:

Rails.application.routes.draw do
  devise_for :users, controllers: { registrations: "users/registrations" }
end

Then we can customise it as follows in app/controllers/users/registrations_controller.rb:

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]

  def new
    super do |resource|
      resource.build_business(params.permit(:plan, :period, :location))
    end
  end

  protected

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [{ business_attributes: [:name, :plan, :period, :location] }])
  end

  def after_sign_up_path_for(resource)
    new_subscription_path
  end
end

As you can see we are building the Business object and allowing its params. We also redirect to the new subscription page after signup.

Subscription page

Now let's create the subscription controller to host the Stripe form and other related actions app/controllers/subscriptions_controller.rb:

class SubscriptionsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_subscription

  def index
  end

  def new
    @client_secret = @subscription.client_secret
  end

  def complete
    @subscription.update_status!
    redirect_to subscriptions_path, notice: "Your subscription was setup successfully"
  end

  def manage
    redirect_to @subscription.customer_portal_session_url(subscriptions_url), allow_other_host: true
  end

  protected

  def set_subscription
    @subscription ||= current_user.business.subscription
  end
end

Here's the new action where we host the Stripe checkout form. As you can see the submit action has been overridden to use a stimulus controller.

We also add the client secret which is specific to the subscription object and the return url which is where Stripe will redirect to after the payment is complete. cardInput and cardError are targets for the stimulus controller to show the Stripe form and any errors.

<!-- app/views/subscriptions/new.html.erb -->

<div class="m-4 w-1/2">
  <%= form_with url: subscription_path, method: :post, data: { controller: "subscription", action: "submit->subscription#submit", subscription_trial_value: @subscription.price.trial? } do |form| %>
    <%= form.hidden_field :client_secret, value: @client_secret, data: { subscription_target: "stripeClientSecret" } %>

    <%= form.hidden_field :return_url, value: complete_subscription_url(only_path: false), data: { subscription_target: "returnUrl" } %>

    <%= tag.div data: { subscription_target: "cardInput" } %>

    <%= tag.div data: { subscription_target: "cardError" }, class: "text-red-600 mb-4 hidden" %>

    <%= form.submit 'Subscribe', data: { subscription_target: "submit", turbo_submits_with: "Submitting..." }, class: "mt-4 cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
  <% end %>
<div>

Here's the associated stimulus controller. We setup the Stripe object with the the Stripe publishable key, client secret and then embed the Stripe form in the cardInput target.

We catch the submit form and send the card details to Stripe. If there is an error we show it to the user, otherwise we allow Stripe to redirect to the return url. You can also see that we get the trial value from the form and choose the appropriate Stripe method to call.

// app/javascript/controllers/subscription_controller.js

import { Controller } from "@hotwired/stimulus";
import { loadStripe } from "@stripe/stripe-js";

export default class extends Controller {
  static targets = [
    "stripeClientSecret",
    "returnUrl",
    "cardInput",
    "cardTokenInput",
    "cardError",
    "submit",
  ];
  static values = { trial: Boolean };

  // See this url for more customization options: https://stripe.com/docs/elements/appearance-api?platform=web#commonly-used-variables
  cardElementParams = () => ({
    clientSecret: this.stripeClientSecretTarget.value,
    appearance: { theme: "stripe", variables: { colorPrimary: "#6366f1" } },
  });

  publishableKey = () =>
    document.querySelector('meta[name="stripe-key"]').getAttribute("content");

  async connect() {
    this.stripe = await loadStripe(this.publishableKey());
    this.elements = this.stripe.elements(this.cardElementParams());
    this.paymentElement = this.elements.create("payment", {
      layout: { type: "tabs", defaultCollapsed: false },
    });
    this.paymentElement.mount(this.cardInputTarget);
  }

  async submit(e) {
    e.preventDefault();

    const originalSubmitText = this.submitTarget.value;
    this.submitTarget.value = this.submitTarget.getAttribute(
      "data-turbo-submits-with"
    );
    this.submitTarget.setAttribute("disabled", "");

    const confirmMethod = this.trialValue ? "confirmSetup" : "confirmPayment";
    const { error } = await this.stripe[confirmMethod]({
      elements: this.elements,
      confirmParams: { return_url: this.returnUrlTarget.value },
    });

    if (error) {
      // This point will only be reached if there is an immediate error when
      // confirming the payment. Show error to your customer (for example, payment
      // details incomplete)
      this.cardErrorTarget.classList.remove("hidden");
      this.cardErrorTarget.textContent = error.message;
      this.submitTarget.removeAttribute("disabled");
      this.submitTarget.value = originalSubmitText;
    } else {
      this.cardErrorTarget.classList.add("hidden");
      // Your customer will be redirected to your `return_url`. For some payment
      // methods like iDEAL, your customer will be redirected to an intermediate
      // site first to authorize the payment, then redirected to the `return_url`.
    }
  }
}

In this app we redirect the user to the subscription show page after the payment is complete where we show them details of their subscription and give them a link to the Stripe Billing Portal:

<!-- app/views/subscriptions/show.html.erb -->

<p>You are on the <%= @subscription.price.plan %> plan, billed <%= humanized_money_with_symbol @subscription.price.cached_price %> <%= @subscription.price.period %>.</p>

<% if @subscription.in_trial_period? %>
  <p>You are on the free trial to <%= @subscription.trial_ends_at.to_fs(:long) %>.</p>
<% else %>
  <p>Your next billing date is <%= @subscription.next_billing_date.to_fs(:long) %>.</p>
<% end %>

<p>Visit the <%= link_to "Stripe Billing Portal", manage_subscription_path, target: :_blank, class: "underline" %> to manage your subscription.</p>

<p><%= link_to "Sign out", destroy_user_session_path, data: { turbo_method: :delete } %></p>

We use a singular route for the subscription show page because there is only one subscription per business:

# config/routes.rb

Rails.application.routes.draw do
  resource :subscription, only: [:new, :show] do
    get :complete, on: :collection
    get :manage, on: :collection
  end
end

Now let's add the Stripe JS file to the app using importmap:

bin/importmap pin @stripe/stripe-js

And add the stripe publishable key to our HTML in the head of our layout along with handling for flash messages:

<!-- app/views/layouts/application.html.erb -->

<html>
  <head>
    <!-- ... -->
    <%= tag.meta name: 'stripe-key', content: Rails.configuration.stripe.publishable_key %>
  </head>
  <body>
    <main>
      <% if alert %><p class="text-red-800 bg-red-50 p-3 mb-3"><%= alert %></p><% end %>
      <% if notice %><p class="text-green-800 bg-green-50 p-3 mb-3"><%= notice %></p><% end %>
      <%= yield %>
    </main>
  </body>
<html>

Manually testing the flow

To manually verify everything is working let's visit the pricing page at /pages/pricing and click on the Standard plan. We should be redirected to the signup page at /users/sign_up with the plan attributes in the params.

We then fill in our details and submit the form. We should be redirected to the new subscription page with the Stripe form at /subscription/new.

If you watch the logs you should see Business, Subscription and User objects being created in the database.

You can fill in the Stripe form with the following test card details:

You should then be redirected to the subscription show page at /subscription via the complete action where you should see the plan you signed up to and the next billing date.

You also have access to the Stripe Billing Portal at /subscription/manage which you can use to update your payment details and cancel your subscription. You can change your billing portal settings here.

We can also change the trial period in the credentials and test the flow again to make sure it works with a trial period set.

Webhooks

Webhooks are a way for Stripe to talk back to your app. They are fired for every single event that occurs for the entire lifecycle of the payment, subscription, invoices etc. We're only interested in the webhooks that will modify our subscription object that occur outside of our app's flow but you can see the full list here.

For instance we create a stripe subscription in the create_stripe_subscription method above and we'll receive a customer.subscription.created event but we're not interested in that because we've already received the subscription object in the response from Stripe.

We are interested in the customer.subscription.updated and customer.subscription.deleted events because they occur outside of our apps flow and we need to update our subscription object accordingly.

If you want to work with webhooks in your local app you're ideally going to need a consistent domain name that maps to your local development environment, otherwise you'll need to change the secret in the credential file every time you restart development.

Ngrok personal ($8pm) is probably the easiest way to do this. It allows you to set up a consistent domain name like myproj.ngrok.io which you can use for testing. Another free option is Ultrahook.

If you're ok with changing the secret in the credential file every time you restart development you can use the free version of ngrok or install the Stripe CLI. Read more about testing webhooks locally.

We're going to use the stripe_event gem to handle webhooks. First we'll install the gem with bundle add stripe_event then we'll mount it in the routes file at config/routes.rb:

mount StripeEvent::Engine, at: "/webhooks/stripe"

Then we'll add the following to the Stripe initializer:

# config/initializers/stripe.rb
...
StripeEvent.signing_secret = Rails.configuration.stripe.signing_secret

StripeEvent.configure do |events|
  events.subscribe "customer.subscription.updated" do |event|
    stripe_subscription = event.data.object
    Subscription.update_from_stripe_subscription(stripe_subscription)
  end

  events.subscribe "customer.subscription.deleted" do |event|
    stripe_subscription = event.data.object
    Subscription.update_from_stripe_subscription(stripe_subscription)
  end

  events.all do |event|
    Rails.logger.info "Stripe webhook event: #{event.type}"
  end
end

If you are using the Stripe CLI you can run stripe listen --forward-to localhost:3000/webhooks/stripe to forward webhooks to your local app.

You can then copy the temporary webhook signing secret (whsec_xxx) from the terminal and add it to your credential file under development:.

If you are using paid Ngrok you'll want to get a permanent webhook signing secret from the Stripe dashboard. Add an endpoint with your chosen domain, choose the events you are interested in (or just add all) and then get the signing secret (whsec_xxx) and add it to your credential file under development:stripe:signing_secret.

Testing

Test framework setup

We use our standard test framework setup layer which includes RSpec, FactoryBot, Cuprite and Capybara, based on the Evil Martians system of a test.

Factories

Once that's set up we need a few factories for our tests:

# spec/factories/businesses.rb

FactoryBot.define do
  factory :business do
    name { Faker::Company.name }
  end
end
# spec/factories/cards.rb

class Card
  attr_accessor :number, :expiry, :cvc, :postal_code, :country
end

FactoryBot.define do
  factory :card do
    number { "4242424242424242" }
    expiry { "12/#{(Date.today.year + 1).to_s.last(2)}" }
    cvc { "123" }
    postal_code { "W1 1AA" }
    country { "United Kingdom" }

    trait :bad do
      number { "4000000000000002" }
    end
  end
end
# spec/factories/subscriptions.rb

FactoryBot.define do
  factory :subscription do
    stripe_id { "sub_xxx" }
    stripe_customer_id { "cus_xxx" }
    stripe_price_id { "price_xxx" }
    full_plan_id { "standard_monthly_uk" }

    association :business, factory: :business, strategy: :build
  end
end
# spec/factories/users.rb

FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    password { Faker::Internet.password }
  end
end

Integration tests

The most important thing we want to test here is the whole signup flow with both success and failure cases and with both trialing and not trialing.

It's worth noting that the requests to Stripe from this flow are actually submitted to the Stripe test servers and not mocked. This has the downsides of being slower and less reliable, but on the upside it's a very realistic test.

There currently isn't a good solution for mocking Stripe as we'd have to mock both Ruby and Stripe.js and the setup for that is outside of the scope of this pattern. It may be something we look at adding in the future.

This test checks the full flow with a good card then checks that we can't access the subscription page if the card is declined. We also test the flow if a trial period is set.

require "system_helper"

RSpec.describe "Business signup" do
  let(:business) { build(:business) }
  let(:user) { build(:user) }
  let(:good_card) { build(:card) }
  let(:bad_card) { build(:card, :bad) }
  let(:plan_params) { { location: :uk, period: :monthly, plan: :standard } }

  it "allows a business to sign up with no trial" do
    visit page_path("pricing")

    click_button "Subscribe", match: :first

    fill_in "Business name", with: business.name
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    fill_in "Password confirmation", with: user.password
    click_button "Sign up"

    expect(page).to have_css('form[data-controller="subscription"]')

    within_frame first('form[data-controller="subscription"] iframe') do
      fill_in "Card number", with: good_card.number
      fill_in "MM / YY", with: good_card.expiry
      fill_in "CVC", with: good_card.cvc
      fill_in "Postal code", with: good_card.postal_code
      select good_card.country, from: "Country"
    end

    click_button "Subscribe"

    expect(page).to have_content("Your subscription was setup successfully")
    expect(page).to have_content("You are on the standard plan, billed £89 monthly.")
    expect(page).to have_content("Your next billing date is #{(Date.today + 30.days).strftime('%B %d, %Y')}")
    expect(page).to have_selector(:link_or_button, "Stripe Billing Portal")
  end

  it "allows a business to sign up with a trial" do
    allow(Rails.application).to receive(:credentials).and_return(Rails.application.credentials.deep_merge(test: { stripe: { trial_periods: { standard_monthly_uk: 30 } } }))

    visit page_path("pricing")

    click_button "Subscribe", match: :first

    fill_in "Business name", with: business.name
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    fill_in "Password confirmation", with: user.password
    click_button "Sign up"

    expect(page).to have_css('form[data-controller="subscription"]')

    within_frame first('form[data-controller="subscription"] iframe') do
      fill_in "Card number", with: good_card.number
      fill_in "MM / YY", with: good_card.expiry
      fill_in "CVC", with: good_card.cvc
      fill_in "Postal code", with: good_card.postal_code
      select good_card.country, from: "Country"
    end

    click_button "Subscribe"

    expect(page).to have_content("Your subscription was setup successfully")
    expect(page).to have_content("You are on the standard plan, billed £89 monthly.")
    expect(page).to have_content("You are on the free trial to #{(Date.today + 30.days).strftime('%B %d, %Y')}")
    expect(page).to have_selector(:link_or_button, "Stripe Billing Portal")
  end

  it "prevents a business from signing up with a bad card" do
    visit page_path("pricing")

    click_button "Subscribe", match: :first

    fill_in "Business name", with: business.name
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    fill_in "Password confirmation", with: user.password
    click_button "Sign up"

    expect(page).to have_css('form[data-controller="subscription"]')

    within_frame first('form[data-controller="subscription"] iframe') do
      fill_in "Card number", with: bad_card.number
      fill_in "MM / YY", with: bad_card.expiry
      fill_in "CVC", with: bad_card.cvc
      fill_in "Postal code", with: bad_card.postal_code
      select bad_card.country, from: "Country"
    end

    click_button "Subscribe"

    expect(page).to have_content("Your card has been declined")

    visit subscription_path

    expect(page).to have_content("You must have an active subscription to access this page")
  end
end

Webhook tests

We also want to test that our webhooks are working correctly. We can do this in a request spec by creating a subscription object in the database and then calling the webhook with json that we've loaded from a file fixture.

We use the bypass_event_signature method to bypass the signature check that Stripe does on the webhook payload.

We set the stripe_id of the subscription object to have the same stripe_id as the webhook payload so that the webhook can find the subscription object in the database.

Then we check that our subscription object has been updated correctly.

# spec/requests/subscription_webhooks_spec.rb

require 'rails_helper'

describe "Billing Events" do
  def bypass_event_signature(payload)
    event = Stripe::Event.construct_from(JSON.parse(payload, symbolize_names: true))
    expect(Stripe::Webhook).to receive(:construct_event).and_return(event)
  end

  before(:each) { bypass_event_signature(payload) }

  describe "customer.subscription.updated" do
    let(:payload) { file_fixture('stripe_subscription_cancelled.json').read }

    let!(:subscription) { create(:subscription, stripe_id: "sub_1NlVT1J8GZd9ITURs6YBgpYl") }

    it "is successful" do
      post "/webhooks/stripe", params: payload

      expect(response).to have_http_status(:success)

      subscription.reload

      expect(subscription.cancel_at_period_end).to eq(true)
    end
  end

  describe "customer.subscription.deleted" do
    let(:payload) { file_fixture('stripe_subscription_deleted.json').read }

    let!(:subscription) { create(:subscription, stripe_id: "sub_1NlVT1J8GZd9ITURs6YBgpYl") }

    it "is successful" do
      post "/webhooks/stripe", params: payload

      expect(response).to have_http_status(:success)

      subscription.reload

      expect(subscription.status).to eq("canceled")
    end
  end
end

Unit tests

Let's also test some of the more complex methods in our models.

# spec/models/price_spec.rb

require "rails_helper"

RSpec.describe Price, type: :model do
  def stub_credentials(**credentials)
    allow(Rails.application).to receive(:credentials).and_return(Rails.application.credentials.deep_merge(**credentials))
  end

  describe "can be created with a full_plan_id" do
    subject(:price) { Price.new(full_plan_id: :standard_monthly_uk) }

    it "is valid" do
      expect(price.plan).to eq("standard")
      expect(price.period).to eq("monthly")
      expect(price.location).to eq("uk")
      expect(price.full_plan_id).to eq("standard_monthly_uk")
      expect(price.stripe_price_id).to start_with("price_")
      expect(price.cached_price).to eq(Money.new(89_00, :GBP))
      expect(price.trial_period_days).to eq(0)
      expect(price.trial?).to eq(false)
    end
  end

  describe "can be created with a plan, period and location" do
    subject(:price) { Price.new(plan: :standard, period: :monthly, location: :uk) }

    it "is valid" do
      expect(price.plan).to eq("standard")
      expect(price.period).to eq("monthly")
      expect(price.location).to eq("uk")
      expect(price.full_plan_id).to eq("standard_monthly_uk")
      expect(price.stripe_price_id).to start_with("price_")
      expect(price.cached_price).to eq(Money.new(89_00, :GBP))
      expect(price.trial_period_days).to eq(0)
      expect(price.trial?).to eq(false)
    end
  end

  describe "trial period can be overriden specifically" do
    subject(:price) { Price.new(full_plan_id: :standard_monthly_uk) }

    it "is valid" do
      expect(price.trial_period_days).to eq(0)
      expect(price.trial?).to eq(false)

      stub_credentials(test: { stripe: { trial_periods: { all: 30 } } })
      expect(price.trial_period_days).to eq(30)
      expect(price.trial?).to eq(true)

      stub_credentials(test: { stripe: { trial_periods: { all: 0, standard: 30 } } })
      expect(price.trial_period_days).to eq(30)
      expect(price.trial?).to eq(true)

      stub_credentials(test: { stripe: { trial_periods: { all: 0, standard: 0, standard_monthly: 30 } } })
      expect(price.trial_period_days).to eq(30)
      expect(price.trial?).to eq(true)

      stub_credentials(test: { stripe: { trial_periods: { all: 0, standard: 0, standard_monthly: 0, standard_monthly_uk: 30 } } })
      expect(price.trial_period_days).to eq(30)
      expect(price.trial?).to eq(true)
    end
  end

  describe "cannot be created without a full_plan_id or plan, period and location" do
    it "raises an ArgumentError" do
      expect { Price.new }.to raise_error(ArgumentError)
    end
  end
end

We can also test the creation of the User object alongside the Business and Subscription objects.

Here we manually set the default_payment_method on the subscription object instead of going through the process of adding a card with Stripe.js.

# spec/models/user_spec.rb

require "rails_helper"

RSpec.describe User, type: :model do
  describe "creation" do
    subject(:user) { User.new(email: Faker::Internet.email, password: Faker::Internet.password, business_attributes: { name: Faker::Company.name, plan: :standard, period: :monthly, location: :uk }) }

    it "can be created with business and subscription" do
      expect(user.save).to be_truthy

      business = user.business
      subscription = business.subscription

      expect(subscription.stripe_id).to start_with("sub_")
      expect(subscription.stripe_customer_id).to start_with("cus_")
      expect(subscription.stripe_price_id).to start_with("price_")
      expect(subscription.full_plan_id).to eq("standard_monthly_uk")
      expect(subscription.status).to eq("incomplete")
      expect(subscription.cancel_at_period_end).to eq(false)

      expect(user.active_for_authentication?).to eq(true)

      subscription.update_columns(default_payment_method: "pm_xxx")

      expect(user.business.subscription.active_or_trialing?).to eq(false)
    end

    it "has an active subscription if the plan is trialing" do
      allow(Rails.application).to receive(:credentials).and_return(Rails.application.credentials.deep_merge(test: { stripe: { trial_periods: { standard_monthly_uk: 30 } } }))

      expect(user.save).to be_truthy

      business = user.business
      subscription = business.subscription

      expect(subscription.status).to eq("trialing")

      subscription.update_columns(default_payment_method: "pm_xxx")

      expect(user.business.subscription.active_or_trialing?).to eq(true)
    end
  end
end

Deployment

To get things ready for production you'll need to recreate the product and prices inside the live Stripe dashboard.

Then copy the live publishable and secret keys into the production credentials file, along with the price ids for each plan.

Finally you'll need to confirm your final production domain then create a webhook endpoint in the Stripe dashboard with that domain.

With that you should be good to go live!

TODO

Full code example

Full diff