Why build this yourself
When it comes to building a subscription flow with Stripe in a Rails app there are a few options:
- Stripe Checkout - A prebuilt checkout form that you can customise with a few options. This has to be the fastest way to get started and it has a reasonable amount of customization options. For instance you can easily add coupons and a trial period. It is however limited to the provided options and you can't add your own fields to the form.
- Stripe low code subscriptions integration - This gives you more flexibility than the entirely hosted checkout above, but it's still a bit limited as portions of it are still hosted by Stripe.
- Pay Gem - This is a great gem that gives you a lot of flexibility and is well maintained. It's a good option especially if you use or plan to use Braintree or Paddle, or use multiple payment providers. It does have a few issues that in my case prevented me from using it:
- Because it supports multiple payment providers it's more complex and crufty than it needs to be. If you only plan to use Stripe it's probably better to interact with Stripe directly.
- It includes a lot of tables in your database that model out a lot of payment objects you may not want in your app.
- It doesn't really take care of the frontend for you so you'll need to build your own payment form.
- Personally I found the documentation unhelpful, and I think that's because it gives you all the elements and how to use them but doesnt really help with the flow and how to piece it together.
If the above options don't appeal you'll want to build it yourself. This will give you all the benefits of DIY:
- Fully customizable to your specific needs
- Run gem and package updates on your own schedule
- No cruft in your codebase or database
- Understand exactly what's going on and how it works
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:
- Dynamic pricing page / pricing grid
- Optional trial period
- Integration with Devise signup
- Separate
User
,Business
andSubscription
objects - Stripe Payment Element
- Stripe Billing Portal
- Webhooks for out of band subscription updates
- Tested with RSpec
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:
- Plan - Standard or Pro
- Period - Monthly or Yearly
- Location - UK (in GBP) or US (in USD)
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:
- We create the Stripe records automatically when the subscription is created
- The
incomplete
status is for when we create the subscription without a trial period, as it's done before taking payment - As you can see there are a couple of conditionals where we need to call different methods depending on whether we are trialling or not
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:
- User visits the pricing page and clicks on a plan
- User is sent to the devise signup page with the plan attributes in the params
- User signs up, at which point the
Business
andSubscription
objects are created along with associated Stripe customer object and incomplete Stripe subscription object - User is sent to the payment page (/subscriptions/new) where they enter their card details and submit the form
- The form handler sends the card details directly to Stripe which then redirects to a url in the app that we specify
- We specify a page that shows the user their subscription information and allows them to access the stripe billing portal
- 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:
- Card number:
4242 4242 4242 4242
- Expiry: Any future date
- CVC: Any 3 digits
- ZIP: Any 5 digits
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
- switch over to default us
- rubocop/lint
- move plan attrs from business to subscription_attributes
- handle errors