Skip to content

Instantly share code, notes, and snippets.

@BrianSigafoos
Last active February 23, 2023 19:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BrianSigafoos/f6a462e7d80b7201689e5bf5ed70448c to your computer and use it in GitHub Desktop.
Save BrianSigafoos/f6a462e7d80b7201689e5bf5ed70448c to your computer and use it in GitHub Desktop.
Rails module to manage Stripe subscriptions from Stripe Checkout

Stripe CLI

  • Install the Stripe CLI with: brew install stripe/stripe-cli/stripe
  • Login to our Stripe account: stripe login
  • Listen for Stripe webhooks using Latest API version and forward to:
    • yarn stripe:listen, which does:
    • stripe listen --latest --forward-to http://localhost:3000/webhook_events/stripe
  • Replay events locally with stripe trigger <event type>:
    • stripe trigger checkout.session.completed
class CreateWebhookEvents < ActiveRecord::Migration[5.2]
def change
create_table :webhook_events do |t|
t.string :source, null: false
t.jsonb :data, default: {}, null: false
t.integer :state, default: 0, null: false
t.string :external_id
t.string :external_type
t.string :processing_errors
t.timestamps
t.index :source
t.index :external_id
t.index :external_type
t.index %i[source external_id]
end
end
end
Rails.application.routes.draw do
# ...
post '/webhook_events/:source', to: 'webhook_events#create'
# ...
end
# Ruby module for entity that will have columsn:
# stripe_subscription_id, subscription_state, product_level columns
module HasStripeSubscription
extend ActiveSupport::Concern
included do
SUBSCRIPTION_STATES = {
none: 0,
trialing: 1,
active: 2,
cancel_at_end: 3,
past_due: 4,
canceled: 5
}.freeze
enum subscription_state: SUBSCRIPTION_STATES, _prefix: true
end
def subscribed?
%w[trialing active cancel_at_end].include?(subscription_state)
end
def update_subscription!(new_stripe_sub_id)
raise 'New stripe_subscription_id missing!' if new_stripe_sub_id.blank?
cancel_other_subscriptions!(new_stripe_sub_id)
self[:stripe_subscription_id] = new_stripe_sub_id
self[:subscription_state] = calc_subscription_state
self[:product_a_level] = calc_product_level(:product_a)
self[:product_b_level] = calc_product_level(:product_b)
self[:product_c_level] = calc_product_level(:product_c)
save!
end
private
# Possible values are incomplete, incomplete_expired, trialing, active,
# past_due, canceled, or unpaid.
def calc_subscription_state
status = stripe_subscription&.status
return :none if status.blank?
return :cancel_at_end if subscription_cancel_at_end?
return status.to_sym if known_subscription_status?(status)
:none # incomplete, incomplete_expired, unpaid
end
def subscription_cancel_at_end?
stripe_subscription&.status == 'active' &&
stripe_subscription.cancel_at_period_end
end
def known_subscription_status?(status)
%w[trialing active past_due canceled].include?(status)
end
def cancel_other_subscriptions!(new_stripe_sub_id)
stripe_customer_subscriptions.each do |str_sub|
next if new_stripe_sub_id == str_sub.id
call_stripe { delete_stripe_subscription(str_sub.id) }
end
end
# From metadata set on each Product and added to each Subscription
# TODO: confirm product_level is available or fallback to default / 'disabled'
def calc_product_level(product)
level = stripe_subscription.metadata[product]
level || 'disabled'
end
def stripe_subscription
return if stripe_subscription_id.blank?
@stripe_subscription ||= call_stripe { retrieve_stripe_subscription }
end
def stripe_customer_subscriptions
return if stripe_customer_id.blank?
@stripe_customer_subscriptions ||= call_stripe { list_stripe_subscriptions }
end
def retrieve_stripe_subscription
::Stripe::Subscription.retrieve(stripe_subscription_id)
end
# Returns array of subscriptions or empty []
def list_stripe_subscriptions
::Stripe::Subscription.list({ customer: stripe_customer_id }).data
end
def delete_stripe_subscription(cancel_stripe_sub_id)
::Stripe::Subscription.delete(cancel_stripe_sub_id)
end
def call_stripe
yield
rescue ::Stripe::StripeError => e
Raven.capture_exception e
nil
end
end
class WebhookEvent < ApplicationRecord
enum state: { pending: 0, processing: 1, processed: 2, failed: 3 }
after_create :process
private
def process
WebhookEventWorker.perform_async(id)
end
end
class WebhookEventWorker
include Sidekiq::Worker
def perform(webhook_event_id)
@event = WebhookEvent.find(webhook_event_id)
return track_error('not processable') unless process_event?
process_event
end
private
attr_reader :event
def process_event?
event.pending? || event.failed?
end
def process_event
event.update(state: :processing)
if event_processer.success?
event.update(state: :processed)
return
end
track_error("processor error: #{event_processer.error}")
rescue StandardError => e
track_error(e)
end
def event_processer
@event_processer ||= determine_event_processor
end
def determine_event_processor
return stripe_event_processor if event.source == 'stripe'
no_processor_for_source
end
def stripe_event_processor
Events::StripeHandler.call(event)
end
def no_processor_for_source
msg = "No processor for source: #{event.source}"
OpenStruct.new(success?: false, error: msg)
end
def track_error(msg, level: :warning)
event.update(state: :failed, processing_errors: msg)
Raven.capture_message(
"WebhookEventWorker #{msg}",
level: level,
extra: { event_id: event&.id }
)
end
end
class WebhookEventsController < ActionController::API
before_action :verify_signature
before_action :find_webhook_event
before_action :create_webhook_event
def create
head :ok
end
private
def verify_signature
return verify_stripe_signature if stripe_event?
handle_error 'unknown webhook source', params[:source]
end
# Reference: https://stripe.com/docs/webhooks/signatures
def verify_stripe_signature
return if verified_stripe_webhook_event.present?
handle_error 'event missing', 'this should never happen!'
rescue JSON::ParserError => e
handle_error 'invalid payload', e
rescue Stripe::SignatureVerificationError => e
handle_error 'invalid signature', e
end
def verified_stripe_webhook_event
Stripe::Webhook.construct_event(
request.body.read, # raw payload
request.env['HTTP_STRIPE_SIGNATURE'], # stripe_signature
Rails.application.config.stripe.fetch(:wh) # secret
)
end
# Do nothing if webhook already received
def find_webhook_event
return if existing_webhook_event.blank?
render json: { message: 'Already processed' }
end
def existing_webhook_event
WebhookEvent.find_by(external_id: external_id, source: params[:source])
end
def create_webhook_event
WebhookEvent.create!(webhook_event_params)
end
def webhook_event_params
{
source: params[:source],
data: webhook_event_data,
external_id: external_id,
external_type: external_type
}
end
def webhook_event_data
params.except(:source, :controller, :action, :webhook_event).permit!
end
def external_id
return params[:id] if stripe_event?
SecureRandom.hex
end
def external_type
return params[:type] if stripe_event?
'unknown'
end
def stripe_event?
params[:source] == 'stripe'
end
def handle_error(error_msg, full_error, status_code = :bad_request)
track_error("WebhookEvents #{error_msg}: #{full_error}")
render json: { message: error_msg }, status: status_code
end
def track_error
# TODO:
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment