From c969acc3fb19cac72d462f8f96feb15124c95a6e Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 17 Dec 2024 13:04:25 +0000 Subject: [PATCH] Persist and process stripe webhooks --- app/controllers/webhooks_controller.rb | 16 ++-- app/jobs/inbound_webhooks/process_job.rb | 11 +++ app/models/inbound_webhook.rb | 7 +- .../inbound_webhooks/create_service.rb | 40 ++++++++++ .../inbound_webhooks/process_service.rb | 48 ++++++++++++ .../stripe/handle_incoming_webhook_service.rb | 13 ++-- .../jobs/inbound_webhooks/process_job_spec.rb | 33 ++++++++ spec/requests/webhooks_controller_spec.rb | 72 ++++++++---------- .../inbound_webhooks/create_service_spec.rb | 61 +++++++++++++++ .../inbound_webhooks/process_service_spec.rb | 75 +++++++++++++++++++ .../handle_incoming_webhook_service_spec.rb | 12 +-- 11 files changed, 320 insertions(+), 68 deletions(-) create mode 100644 app/jobs/inbound_webhooks/process_job.rb create mode 100644 app/services/inbound_webhooks/create_service.rb create mode 100644 app/services/inbound_webhooks/process_service.rb create mode 100644 spec/jobs/inbound_webhooks/process_job_spec.rb create mode 100644 spec/services/inbound_webhooks/create_service_spec.rb create mode 100644 spec/services/inbound_webhooks/process_service_spec.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 51c0f697b68..1922cb628f1 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -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 diff --git a/app/jobs/inbound_webhooks/process_job.rb b/app/jobs/inbound_webhooks/process_job.rb new file mode 100644 index 00000000000..7ef646e9373 --- /dev/null +++ b/app/jobs/inbound_webhooks/process_job.rb @@ -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 diff --git a/app/models/inbound_webhook.rb b/app/models/inbound_webhook.rb index 994e91b9f30..cf3d85a6508 100644 --- a/app/models/inbound_webhook.rb +++ b/app/models/inbound_webhook.rb @@ -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 diff --git a/app/services/inbound_webhooks/create_service.rb b/app/services/inbound_webhooks/create_service.rb new file mode 100644 index 00000000000..9dce52f42f3 --- /dev/null +++ b/app/services/inbound_webhooks/create_service.rb @@ -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 diff --git a/app/services/inbound_webhooks/process_service.rb b/app/services/inbound_webhooks/process_service.rb new file mode 100644 index 00000000000..2646defed74 --- /dev/null +++ b/app/services/inbound_webhooks/process_service.rb @@ -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 diff --git a/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb b/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb index 333094718bc..c254496aa0f 100644 --- a/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb +++ b/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb @@ -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 @@ -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 ) @@ -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 diff --git a/spec/jobs/inbound_webhooks/process_job_spec.rb b/spec/jobs/inbound_webhooks/process_job_spec.rb new file mode 100644 index 00000000000..765038121e9 --- /dev/null +++ b/spec/jobs/inbound_webhooks/process_job_spec.rb @@ -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 diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb index 65fd6f86315..6c523334a46 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -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 diff --git a/spec/services/inbound_webhooks/create_service_spec.rb b/spec/services/inbound_webhooks/create_service_spec.rb new file mode 100644 index 00000000000..f14db412f2c --- /dev/null +++ b/spec/services/inbound_webhooks/create_service_spec.rb @@ -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 diff --git a/spec/services/inbound_webhooks/process_service_spec.rb b/spec/services/inbound_webhooks/process_service_spec.rb new file mode 100644 index 00000000000..ef5fb517dec --- /dev/null +++ b/spec/services/inbound_webhooks/process_service_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhooks::ProcessService, type: :service do + subject(:result) { described_class.call(inbound_webhook:) } + + let(:inbound_webhook) { create :inbound_webhook, source: webhook_source } + let(:webhook_source) { "stripe" } + let(:handle_incoming_webhook_service_result) { BaseService::Result.new } + + before do + allow(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to receive(:call) + .and_return(handle_incoming_webhook_service_result) + end + + it "updateds inbound webhook status to processing" do + allow(inbound_webhook).to receive(:processing!) + + result + expect(inbound_webhook).to have_received(:processing!).once + end + + context "when inbound webhook source is invalid" do + let(:webhook_source) { "invalid_source" } + + it "flags inbound webhook as failed and raises an error" do + expect { result } + .to change(inbound_webhook, :status).to("failed") + .and raise_error( + NameError, + "Invalid inbound webhook source: invalid_source" + ) + end + end + + context "when webhook source is Stripe" do + let(:webhook_source) { "stripe" } + + before do + allow(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to receive(:call) + .and_return(handle_incoming_webhook_service_result) + end + + it "delegates the call to the Stripe webhook hanlder service" do + expect(result).to be_success + expect(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to have_received(:call) + .with(inbound_webhook:) + end + + it "updated inbound webhook status to processed" do + expect { result }.to change(inbound_webhook, :status).to("processed") + end + + context "when the stripe webhook handling fails" do + before do + handle_incoming_webhook_service_result.service_failure!( + code: "error", message: "error message" + ) + end + + it "returns the handler results" do + expect(result).not_to be_success + expect(result).to eq(handle_incoming_webhook_service_result) + end + + it "updates inbound webhook status to failed" do + expect { result }.to change(inbound_webhook, :status).to("failed") + end + end + end +end diff --git a/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb index 8b4bdb8120d..56b13b28737 100644 --- a/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb +++ b/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb @@ -3,18 +3,12 @@ require "rails_helper" RSpec.describe PaymentProviders::Stripe::HandleIncomingWebhookService, type: :service do - subject(:result) do - described_class.call( - organization_id: organization.id, - body: event.to_json, - signature: "signature", - code: - ) - end + subject(:result) { described_class.call(inbound_webhook:) } + let(:inbound_webhook) { create :inbound_webhook, organization:, code: } + let(:code) { nil } let(:membership) { create(:membership) } let(:organization) { membership.organization } - let(:code) { nil } let(:stripe_provider) { create(:stripe_provider, organization:) } let(:event_result) { Stripe::Event.construct_from(event) }