Wiring Up Stripe
Payments is one of the places where we violate our own advice to use rest APIs directly instead of using Gems. In the case of payments, there is quite a lot to do in order to support the basics, so rolling our own solution here would be hard to keep updated and maintain consistency with over time. For example:
Maintaining our own database records for customers, charges, subscriptions.
Keeping everything in sync in our system when activity happens on the Stripe Platform
It just so happens that there's a very good gem for that.
Pay Gem Setup
Create and run the migrations
rails pay:install:migrations
If your user model happens to have an id
column that's not the same type as your other id columns (usually integer), you should open up the migration file before running rails db:migrate
and update the column type to the correct field type around line 6. This can happen if you're using uuid for user ids for example.
rails db:migrate
Add an initializer
# config/initializers/pay.rb
Pay.setup do |config|
# For use in the receipt/refund/renewal mailers
config.business_name = "My Biz"
config.business_address = ""
config.application_name = "My App"
config.support_email = "Scout <support@use-scout.com>"
config.default_product_name = "default"
config.default_plan_name = "default"
config.automount_routes = true
config.routes_path = "/pay" # Only when automount_routes is true
# All processors are enabled by default. If a processor is already implemented in your application, you can omit it from this list and the processor will not be set up through the Pay gem.
config.enabled_processors = [:stripe, :braintree, :paddle_billing, :paddle_classic, :lemon_squeezy]
# To disable all emails, set the following configuration option to false:
config.send_emails = true
# By default emails are sent via Pay::UserMailer which inherits from Pay::ApplicationMailer. Instead, you may wish to inherit from ApplicationMailer, or use a different mailer entirely.
config.parent_mailer = "ApplicationMailer"
config.mailer = "MyCustomPayMailer"
# All emails can be configured independently as to whether to be sent or not. The values can be set to true, false or a custom lambda to set up more involved logic. The Pay defaults are show below and can be modified as needed.
config.emails.payment_action_required = true
config.emails.payment_failed = true
config.emails.receipt = true
config.emails.refund = true
# This example for subscription_renewing only applies to Stripe, therefore we supply the second argument of price
config.emails.subscription_renewing = ->(pay_subscription, price) {
(price&.type == "recurring") && (price.recurring&.interval == "year")
}
config.emails.subscription_trial_will_end = true
config.emails.subscription_trial_ended = true
end
Update the user model
# Tell the user model to use the pay gem
pay_customer default_payment_processor: :stripe, stripe_attributes: :stripe_attributes
# Set up the data to be sent to Stripe
def stripe_attributes(pay_customer)
{
metadata: {
pay_customer_id: pay_customer.id,
user_id: id
}
}
end
Set Up Webhooks On Stripe
We need Stripe to notify our app when things change over there. To do this
Open the stripe dashboard at https://dashboard.stripe.com/
Go to webhooks -> create
Select all of the relevant events - see below
Set the url to
https://ourapp.com/pay/webhooks/stripe
Events
Account
account.updated
Charge
charge.dispute.created
charge.succeeded
charge.updated
Checkout
checkout.session.async_payment_succeeded
checkout.session.completed
Customer
customer.deleted
customer.updated
customer.subscription.created
customer.subscription.deleted
customer.subscription.trial_will_end
customer.subscription.updated
Invoice
invoice.payment_action_required
invoice.payment_failed
invoice.upcoming
Payment Intent
payment_intent.succeeded
Payment Method
payment_method.attached
payment_method.detached
payment_method.updated
Subscription Schedule
subscription_schedule.created
A note on event types
The list of all webhooks the pay gem supports are here. Most of the file names correspond directly to event names in stripe, except for
payment_action_required
->invoice.payment_action_required
payment_failed
->invoice.payment_failed
subscription_created
->customer_subscription_created
subscription_renewing
->invoice.upcoming
Add sections to Active Admin
Add one file per Pay Database Model.
# app/admin/pay_customers.rb
ActiveAdmin.register Pay::Customer do
menu parent: "Pay"
end
# app/admin/pay_merchants.rb
ActiveAdmin.register Pay::Merchant do
menu parent: "Pay"
end
# app/admin/pay_payment_methods.rb
ActiveAdmin.register Pay::PaymentMethod do
menu parent: "Pay"
end
# app/admin/pay_subscriptions.rb
ActiveAdmin.register Pay::Subscription do
menu parent: "Pay"
end
# app/admin/pay_webhooks.rb
ActiveAdmin.register Pay::Webhook do
menu parent: "Pay"
end
# app/admin/pay_charges.rb
ActiveAdmin.register Pay::Charge do
menu parent: "Pay"
end
Further Setup
The previous steps ensure that our app data gets updated when user actions happen on Stripe. The following steps add the necessary screens and routes into our app.
Allow user to see what plan they're subscribed to and when (if) it expires.
Allow user to click a "Subscribe" button if they don't already have a plan (the actual credit card entry happens on Stripe).
Allow user to click a "Manage Subscription" button which takes them to a customer portal where they can manage or cancel their subscription.
Add Routes
# config/routes.rb
get "/account/plan" => "account#plan", as: :account_plan
get "/account/subscribe" => "account#subscribe", as: :account_subscribe
get "/account/manage" => "account#manage_subscription", as: :account_manage_subscription
get "/account/checkout/final" => "account#checkout_final", as: :account_checkout_final
Model Methods
Include concern in user model
# app/models/user.rb
include StripeStuff
Create The Concern
module StripeStuff
extend ActiveSupport::Concern
included do
# Stripe Payment Stuff (Uses the "pay" gem)
pay_customer default_payment_processor: :stripe, stripe_attributes: :stripe_attributes
def self.sync_all_users_to_stripe
User.all.each do |user|
user.initialize_stripe_customer
end
end
def initialize_stripe_customer
record = self.payment_processor.api_record
end
def stripe_customer_record
self.pay_customers.find_by(default:true)
end
def stripe_subscriptions
self.stripe_customer_record.subscriptions
end
def active_subscription
self.stripe_subscriptions.find_by(status: "active")
end
def stripe_attributes(pay_customer)
{
name: self.display_name,
metadata: {
pay_customer_id: pay_customer.id,
user_id: id
}
}
end
def generate_stripe_checkout_session(price_id)
session = Stripe::Checkout::Session.create(
customer: self.payment_processor.processor_id,
mode: 'subscription',
line_items: [{
price: price_id,
quantity: 1,
}],
success_url: Rails.application.routes.url_helpers.account_checkout_final_url,
cancel_url: Rails.application.routes.url_helpers.account_checkout_final_url(fail: true),
metadata: {
user_id: self.id
}
)
return session
end
def generate_stripe_customer_portal_session
session = Stripe::BillingPortal::Session.create(
customer: self.payment_processor.processor_id,
return_url: Rails.application.routes.url_helpers.account_plan_url
)
return session
end
end
end
Controller
class AccountController < ApplicationController
before_action :authenticate_user!
def plan
end
def subscribe
redirect_to request.referer unless params[:price_id].present?
@session = current_user.generate_stripe_checkout_session(params[:price_id])
redirect_to @session.url, allow_other_host: true
end
def manage_subscription
@session = current_user.generate_stripe_customer_portal_session
redirect_to @session.url, allow_other_host: true
end
end
Stripe Utils
This is a tiny helper that we can use to enrich the raw data stored in the database which can be used in views, controllers, or emails
# app/utils/stripe_utils.rb
module StripeUtils
def self.map_price_id_to_plan_name(price_id)
case price_id
when "price_1RmUJ0EOLzzaqKQSvtMI70P6"
"Pro"
end
end
def self.price_description_from_plan_object(plan_object)
currency_symbol = case plan_object["plan"]["currency"]
when "usd"
"$"
when "eur"
"€"
when "gbp"
"£"
end
amount = plan_object["plan"]["amount"] / 100
"#{currency_symbol}#{amount}/#{plan_object["plan"]["interval"]}"
end
end
Views
<div class="container" >
<div class="flex items-center justify-between my-8">
<h1 class="ui-title --xl">
My Plan
</h1>
</div>
<!-- if user has a subscription, show info and Manage Button -->
<% if current_user.active_subscription %>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="ui-box">
<div class="flex justify-between items-center">
<div class="ui-titlepair --xl">
<h3 class="--title">
<%= StripeUtils.map_price_id_to_plan_name(current_user.active_subscription.object["plan"]["id"]) %>
</h3>
<p class="ui-text --sm">
<%= StripeUtils.price_description_from_plan_object(current_user.active_subscription.object) %>
</p>
</div>
<div class="flex items-center gap-2">
<% if current_user.active_subscription.ends_at.present? %>
<p class="ui-text --sm">
Expires <%= current_user.active_subscription.ends_at.strftime("%B %d, %Y") %>
</p>
<% end %>
<a class="ui-button --solid --motion-forward"
href="<%= account_manage_subscription_path %>"
hx-boost="false"
>
Manage
<%= inline_svg("icons/heroicons/arrow-right.svg", class: "w-4 h-4") %>
</a>
</div>
</div>
</div>
</div>
</div>
<% else %>
<!-- Otherwise, show the subscribe button -->
<a class="ui-button --solid"
href="<%= account_subscribe_path(price_id: 'price_1RmUJ0EOLzzaqKQSvtMI70P6') %>"
hx-boost="false"
>
Subscribe
</a>
<% end %>
</div>
Last updated