Last active
December 12, 2022 23:59
-
-
Save ryenski/2122be572c457e856c1cc89a23aab356 to your computer and use it in GitHub Desktop.
Stripe Webhooks Controller
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
# 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 |
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
# 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 |
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
# 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 |
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
# 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 |
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
# 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