Skip to content

Instantly share code, notes, and snippets.

@ryenski
Last active December 12, 2022 23:59
Show Gist options
  • Save ryenski/2122be572c457e856c1cc89a23aab356 to your computer and use it in GitHub Desktop.
Save ryenski/2122be572c457e856c1cc89a23aab356 to your computer and use it in GitHub Desktop.
Stripe Webhooks Controller
# This example follows a Stripe webhook through the application life cycle.
# In this case, a customer has created a Checkout, which is an object in our application that acts like a shopping cart.
# It relates to the products that are being purchased and encapsulates the customer's existing record, or the data required to create a new customer.
class Checkout < ApplicationRecord
include Amountable
include BtcpayInvoice
include StripePaymentIntent
belongs_to :user, optional: true
has_one :organization, through: :campaign
belongs_to :customer, optional: true
has_one :payment, dependent: :nullify
has_paper_trail
enum currency: { usd: 'usd', btc: 'btc' }
# incomplete - new record, no data collected
# addressing - collecting address data
# paying - collecting payment data
# pending - Checkout is submitted to payment processor, but confirmation is not yet received
# complete - Payment has been received but may not yet be confirmed
# failed - Payment has failed
# confirmed - Conformation received by processor
enum :status, %i[
incomplete
addressing
collecting
pending
complete
failed
confirmed
]
# Capture customer data to be passed to relevant records after checkout is complete
store_accessor :metadata, :email, :name, :street, :city, :state, :postal_code, :phone
# Attribute delegation makes the forms easier to build in the UI
delegate :name, :slug, to: :organization, prefix: true, allow_nil: true
delegate :name, to: :customer, prefix: true, allow_nil: true
delegate :email, to: :user, prefix: true, allow_nil: true
before_save :lookup_customer
def lookup_customer
return if customer
self.customer = organization.customers.includes(:user).find_by('users.email': email)
end
# This object is saved several times before the payment is complete. At each
# step of the checkout process, we collect more information about the
# customer. When the payment is complete, we will receive a callback from the
# processor (either Stripe or BTCPayServer). That callback will be called
# asynchronously, but we need to capture and display the payment receipt
# before final confirmation is received. At that point, this checkout has been
# completed, but we're just waiting for the final confirmation from the
# processor. The `complete!` method creates the customer unless it already
# exists. Then it creates the payment (in a pending state) or finds the
# payment if it has already been received by the webhook. Finally, the UI will
# display the receipt (with the payment status).
def complete!
super
create_or_update_customer
find_or_create_payment
end
def create_or_update_customer
self.customer ||= Customer.new(organization: organization)
customer.user = user
customer.assign_attributes(customer_attributes)
end
def customer_attributes
{ name: name,
street: street,
city: city,
state: state,
postal_code: postal_code,
phone: phone,
metadata: {
referred_by_text: referred_by_text
} }
end
def find_or_create_payment
self.payment ||= Payment.new(
organization: organization,
customer: customer,
campaign: campaign,
amount: amount
)
end
def user
@user ||= User.find_or_initialize_by(email: email)
end
end
# Full list of Stripe webhook events:
# https://stripe.com/docs/api/events/types
class StripeEvent
# Enumerate supported webhook events:
EVENTS = {
'charge.succeeded' => 'StripeEvents::ChargeSucceeded',
'customer.subscription.updated' => 'StripeEvents::CustomerSubscriptionUpdated',
'customer.subscription.created' => 'StripeEvents::CustomerSubscriptionCreated',
'customer.subscription.deleted' => 'StripeEvents::CustomerSubscriptionDeleted',
'payment_intent.created' => 'StripeEvents::PaymentIntentCreated',
'payment_intent.processing' => 'StripeEvents::PaymentIntentProcessing',
'payment_intent.requires_action' => 'StripeEvents::PaymentIntentRequiresAction',
'financial_connections.account.created' => 'StripeEvents::FinancialConnectionsAccountCreated',
}.freeze
include ActiveModel::Model
attr_accessor :event
def organization_id
organization = Organization.find_by!(stripe_account_id: event.account)
organization.id
end
# TODO: process in background job
def self.call(event_type, event)
if (event_obj = EVENTS[event_type]&.constantize)
event_obj.new(event: event).call
end
end
end
# The ChargeSucceeded webhook occurs whenever a charge is successful.
# https://stripe.com/docs/api/events/types#event_types-charge.succeeded This
# webhook instantiates the ImportCharges object, which handles the work of
# looking up or creating a new payment.
class StripeEvents::ChargeSucceeded < StripeEvent
def call
Stripe::ImportCharges.new(item_to_import: event.data.object, organization_id: organization_id).save
end
end
# The PaymentIntentProcessing webhook occurs when a PaymentIntent has started processing.
# https://stripe.com/docs/api/events/types#event_types-payment_intent.processing
#
# The PaymentIntent object:
# https://stripe.com/docs/api/payment_intents/object
module StripeEvents
class PaymentIntentProcessing
include ActiveModel::Model
attr_accessor :event
def call
checkout = Checkout.find_by!("stripe->>'payment_intent_id' = ?", event.data.object.id)
checkout.update(
stripe_status: event.data.object.status,
status: 'pending'
)
self
end
end
end
# The WebhooksController receives the request from the Stripe webhook, and
# routes it to the proper StripeEvent object.
class Integrations::Stripe::WebhooksController < ActionController::API
respond_to :json
def create
# Verify webhook signature and extract the event
# See https://stripe.com/docs/webhooks/signatures for more information.
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
webhook_secret = ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
payload = request.raw_post
begin
event = ::Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
rescue JSON::ParserError => e
# Invalid payload
head(400) && (return)
rescue ::Stripe::SignatureVerificationError => e
# Invalid signature
head(400) && (return)
end
begin
StripeEvent.call(event['type'], event)
rescue ActiveRecord::RecordNotFound
head(404) && (return)
end
head(200) && return
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment