Skip to content

Commit

Permalink
Persist and process stripe webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
ancorcruz committed Dec 17, 2024
1 parent 8bb9084 commit c969acc
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 68 deletions.
16 changes: 6 additions & 10 deletions app/controllers/webhooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@

class WebhooksController < ApplicationController
def stripe
result = PaymentProviders::Stripe::HandleIncomingWebhookService.call(
result = InboundWebhooks::CreateService.call(
organization_id: params[:organization_id],
webhook_source: :stripe,
code: params[:code].presence,
body: request.body.read,
signature: request.headers['HTTP_STRIPE_SIGNATURE']
payload: request.body.read,
signature: request.headers["HTTP_STRIPE_SIGNATURE"],
event_type: params[:type]
)

unless result.success?
if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == 'webhook_error'
return head(:bad_request)
end

result.raise_if_error!
end
return head(:bad_request) unless result.success?

head(:ok)
end
Expand Down
11 changes: 11 additions & 0 deletions app/jobs/inbound_webhooks/process_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module InboundWebhooks
class ProcessJob < ApplicationJob
queue_as :default

def perform(inbound_webhook:)
InboundWebhooks::ProcessService.call(inbound_webhook:).raise_if_error!
end
end
end
7 changes: 6 additions & 1 deletion app/models/inbound_webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ class InboundWebhook < ApplicationRecord

validates :event_type, :payload, :source, :status, presence: true

STATUSES = {pending: "pending"}
STATUSES = {
pending: "pending",
processing: "processing",
processed: "processed",
failed: "failed"
}

enum :status, STATUSES
end
Expand Down
40 changes: 40 additions & 0 deletions app/services/inbound_webhooks/create_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module InboundWebhooks
class CreateService < BaseService
def initialize(organization_id:, webhook_source:, payload:, event_type:, code: nil, signature: nil)
@organization_id = organization_id
@webhook_source = webhook_source
@code = code
@payload = payload
@signature = signature
@event_type = event_type

super
end

def call
inbound_webhook = InboundWebhook.create!(
organization_id:,
source: webhook_source,
code:,
payload:,
signature:,
event_type:
)

after_commit do
InboundWebhooks::ProcessJob.perform_later(inbound_webhook:)
end

result.inbound_webhook = inbound_webhook
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end

private

attr_reader :organization_id, :webhook_source, :code, :payload, :signature, :event_type
end
end
48 changes: 48 additions & 0 deletions app/services/inbound_webhooks/process_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module InboundWebhooks
class ProcessService < BaseService
WEBHOOK_HANDLER_SERVICES = {
stripe: PaymentProviders::Stripe::HandleIncomingWebhookService
}

def initialize(inbound_webhook:)
@inbound_webhook = inbound_webhook

super
end

def call
inbound_webhook.processing!

handler_result = handler_service_klass.call(inbound_webhook:)

unless handler_result.success?
inbound_webhook.failed!
return handler_result
end

inbound_webhook.processed!

result.inbound_webhook = inbound_webhook
result
rescue
inbound_webhook.failed!
raise
end

private

attr_reader :inbound_webhook

def handler_service_klass
WEBHOOK_HANDLER_SERVICES.fetch(webhook_source) do
raise NameError, "Invalid inbound webhook source: #{webhook_source}"
end
end

def webhook_source
inbound_webhook.source.to_sym
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
module PaymentProviders
module Stripe
class HandleIncomingWebhookService < BaseService
def initialize(organization_id:, body:, signature:, code: nil)
@organization_id = organization_id
@body = body
@signature = signature
@code = code
extend Forwardable

def initialize(inbound_webhook:)
@inbound_webhook = inbound_webhook

super
end
Expand All @@ -22,7 +21,7 @@ def call
return payment_provider_result unless payment_provider_result.success?

event = ::Stripe::Webhook.construct_event(
body,
payload,
signature,
payment_provider_result.payment_provider&.webhook_secret
)
Expand All @@ -42,7 +41,7 @@ def call

private

attr_reader :organization_id, :body, :signature, :code
def_delegators :@inbound_webhook, :code, :organization_id, :payload, :signature
end
end
end
33 changes: 33 additions & 0 deletions spec/jobs/inbound_webhooks/process_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe InboundWebhooks::ProcessJob, type: :job do
subject(:process_job) { described_class }

let(:inbound_webhook) { create :inbound_webhook }
let(:result) { BaseService::Result.new }

before do
allow(InboundWebhooks::ProcessService).to receive(:call).and_return(result)
end

it "calls the process webhook service" do
process_job.perform_now(inbound_webhook:)

expect(InboundWebhooks::ProcessService)
.to have_received(:call)
.with(inbound_webhook:)
end

context "when result is a failure" do
let(:result) do
BaseService::Result.new.service_failure!(code: "error", message: "error message")
end

it "raises an error" do
expect { process_job.perform_now(inbound_webhook:) }
.to raise_error(BaseService::FailedResult)
end
end
end
72 changes: 31 additions & 41 deletions spec/requests/webhooks_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -1,77 +1,67 @@
# frozen_string_literal: true

require 'rails_helper'
require "rails_helper"

RSpec.describe WebhooksController, type: :request do
describe 'POST /stripe' do
let(:organization) { create(:organization) }

let(:stripe_provider) do
create(
:stripe_provider,
organization:,
webhook_secret: 'secrests'
)
end

let(:stripe_service) { instance_double(PaymentProviders::StripeService) }
describe "POST /stripe" do
let(:organization_id) { Faker::Internet.uuid }
let(:code) { "stripe_1" }
let(:signature) { "signature" }
let(:event_type) { "payment_intent.succeeded" }

let(:event) do
path = Rails.root.join('spec/fixtures/stripe/payment_intent_event.json')
path = Rails.root.join("spec/fixtures/stripe/payment_intent_event.json")
JSON.parse(File.read(path))
end

let(:result) do
result = BaseService::Result.new
result.event = Stripe::Event.construct_from(event)
result
end
let(:payload) { event.merge(code:) }
let(:result) { BaseService::Result.new }

before do
allow(PaymentProviders::Stripe::HandleIncomingWebhookService)
allow(InboundWebhooks::CreateService)
.to receive(:call)
.with(
organization_id: organization.id,
code: nil,
body: event.to_json,
signature: 'signature'
organization_id:,
webhook_source: :stripe,
code:,
payload: payload.to_json,
signature:,
event_type:
)
.and_return(result)
end

it 'handle stripe webhooks' do
it "handle stripe webhooks" do
post(
"/webhooks/stripe/#{stripe_provider.organization_id}",
params: event.to_json,
"/webhooks/stripe/#{organization_id}",
params: payload.to_json,
headers: {
'HTTP_STRIPE_SIGNATURE' => 'signature',
'Content-Type' => 'application/json'
"HTTP_STRIPE_SIGNATURE" => signature,
"Content-Type" => "application/json"
}
)

expect(response).to have_http_status(:success)
expect(PaymentProviders::Stripe::HandleIncomingWebhookService)
.to have_received(:call)
expect(InboundWebhooks::CreateService).to have_received(:call)
end

context 'when failing to handle stripe event' do
let(:result) do
BaseService::Result.new.service_failure!(code: 'webhook_error', message: 'Invalid payload')
context "when InboundWebhooks::CreateService is not successful" do
before do
result.record_validation_failure!(record: build(:inbound_webhook))
end

it 'returns a bad request' do
it "returns a bad request" do
post(
"/webhooks/stripe/#{stripe_provider.organization_id}",
params: event.to_json,
"/webhooks/stripe/#{organization_id}",
params: payload.to_json,
headers: {
'HTTP_STRIPE_SIGNATURE' => 'signature',
'Content-Type' => 'application/json'
"HTTP_STRIPE_SIGNATURE" => signature,
"Content-Type" => "application/json"
}
)

expect(response).to have_http_status(:bad_request)
expect(PaymentProviders::Stripe::HandleIncomingWebhookService)
.to have_received(:call)
expect(InboundWebhooks::CreateService).to have_received(:call)
end
end
end
Expand Down
61 changes: 61 additions & 0 deletions spec/services/inbound_webhooks/create_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe InboundWebhooks::CreateService, type: :service do
subject(:result) do
described_class.call(
organization_id: organization.id,
webhook_source:,
code:,
payload:,
signature:,
event_type:
)
end

let(:organization) { create :organization }
let(:code) { "stripe_1" }
let(:webhook_source) { "stripe" }
let(:signature) { "signature" }
let(:payload) { event.merge(code:).to_json }
let(:event_type) { "payment_intent.successful" }

let(:event) do
path = Rails.root.join("spec/fixtures/stripe/payment_intent_event.json")
JSON.parse(File.read(path))
end

it "creates an inbound webhook" do
expect { result }.to change(InboundWebhook, :count).by(1)
end

it "returns a pending inbound webhook in the result" do
expect(result.inbound_webhook).to be_a(InboundWebhook)
expect(result.inbound_webhook).to be_pending
end

it "queues an InboundWebhook::ProcessJob job" do
result

expect(InboundWebhooks::ProcessJob)
.to have_been_enqueued
.with(inbound_webhook: result.inbound_webhook)
end

context "with validation error" do
let(:webhook_source) { nil }

it "returns an error" do
expect(result).not_to be_success
expect(result.error).to be_a(BaseService::ValidationFailure)
expect(result.error.messages[:source]).to eq(["value_is_mandatory"])
end

it "does not queue an InboundWebhook::ProcessJob job" do
result

expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued
end
end
end
Loading

0 comments on commit c969acc

Please sign in to comment.