- 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
Last active
February 23, 2023 19:58
-
-
Save BrianSigafoos/f6a462e7d80b7201689e5bf5ed70448c to your computer and use it in GitHub Desktop.
Rails module to manage Stripe subscriptions from Stripe Checkout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Rails.application.routes.draw do | |
# ... | |
post '/webhook_events/:source', to: 'webhook_events#create' | |
# ... | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
Based on code from https://github.com/stripe-samples/developer-office-hours/tree/webhooks-demo/2019-10-16-webhooks