diff --git a/app/jobs/send_webhook_job.rb b/app/jobs/send_webhook_job.rb index 73c39534cf5..0d3b226318f 100644 --- a/app/jobs/send_webhook_job.rb +++ b/app/jobs/send_webhook_job.rb @@ -36,6 +36,7 @@ class SendWebhookJob < ApplicationJob 'payment_provider.error' => Webhooks::PaymentProviders::ErrorService, 'payment_request.created' => Webhooks::PaymentRequests::CreatedService, "payment_request.payment_failure" => Webhooks::PaymentProviders::PaymentRequestPaymentFailureService, + "payment_request.payment_status_updated" => Webhooks::PaymentRequests::PaymentStatusUpdatedService, 'subscription.terminated' => Webhooks::Subscriptions::TerminatedService, 'subscription.started' => Webhooks::Subscriptions::StartedService, 'subscription.termination_alert' => Webhooks::Subscriptions::TerminationAlertService, diff --git a/app/services/payment_requests/payments/gocardless_service.rb b/app/services/payment_requests/payments/gocardless_service.rb index aac7c748c22..9b2adc54a31 100644 --- a/app/services/payment_requests/payments/gocardless_service.rb +++ b/app/services/payment_requests/payments/gocardless_service.rb @@ -72,6 +72,25 @@ def create result end + def update_payment_status(provider_payment_id:, status:) + payment = Payment.find_by(provider_payment_id:) + return result.not_found_failure!(resource: 'gocardless_payment') unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.update!(status:) + + payable_payment_status = payable_payment_status(status) + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + private attr_accessor :payable @@ -149,14 +168,18 @@ def payable_payment_status(payment_status) end def update_payable_payment_status(payment_status:, deliver_webhook: true) - payable.update!( - payment_status:, - ready_for_payment_processing: payment_status.to_sym != :succeeded - ) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! end def update_invoices_payment_status(payment_status:, deliver_webhook: true) - payable.invoices.each do |invoice| + result.payable.invoices.each do |invoice| Invoices::UpdateService.call( invoice:, params: { diff --git a/app/services/payment_requests/update_service.rb b/app/services/payment_requests/update_service.rb new file mode 100644 index 00000000000..f0d46932d51 --- /dev/null +++ b/app/services/payment_requests/update_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module PaymentRequests + class UpdateService < BaseService + def initialize(payable:, params:, webhook_notification: false) + @payable = payable + @params = params + @webhook_notification = webhook_notification + + super + end + + def call + return result.not_found_failure!(resource: "payment_request") unless payable + + if params.key?(:payment_status) && !valid_payment_status?(params[:payment_status]) + return result.single_validation_failure!( + field: :payment_status, + error_code: "value_is_invalid" + ) + end + + payable.payment_status = params[:payment_status] if params.key?(:payment_status) + payable.ready_for_payment_processing = params[:ready_for_payment_processing] if params.key?(:ready_for_payment_processing) + payable.save! + + if payable.saved_change_to_payment_status? + track_payment_status_changed + deliver_webhook if webhook_notification + end + + result.payable = payable + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :payable, :params, :webhook_notification + + def valid_payment_status?(payment_status) + PaymentRequest::PAYMENT_STATUS.include?(payment_status&.to_sym) + end + + def track_payment_status_changed + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "payment_status_changed", + properties: { + organization_id: payable.organization.id, + payment_request_id: payable.id, + payment_status: payable.payment_status + } + ) + end + + def deliver_webhook + return unless webhook_notification + + SendWebhookJob.perform_later("payment_request.payment_status_updated", payable) + end + end +end diff --git a/app/services/webhooks/payment_requests/payment_status_updated_service.rb b/app/services/webhooks/payment_requests/payment_status_updated_service.rb new file mode 100644 index 00000000000..b4ee5c96201 --- /dev/null +++ b/app/services/webhooks/payment_requests/payment_status_updated_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentRequests + class PaymentStatusUpdatedService < Webhooks::BaseService + def current_organization + @current_organization ||= object.organization + end + + def object_serializer + ::V1::PaymentRequestSerializer.new( + object, + root_name: "payment_request", + includes: %i[customer invoices] + ) + end + + def webhook_type + 'payment_request.payment_status_updated' + end + + def object_type + 'payment_request' + end + end + end +end diff --git a/spec/services/payment_requests/payments/gocardless_service_spec.rb b/spec/services/payment_requests/payments/gocardless_service_spec.rb index 7dc9606817a..fb39f0738ef 100644 --- a/spec/services/payment_requests/payments/gocardless_service_spec.rb +++ b/spec/services/payment_requests/payments/gocardless_service_spec.rb @@ -116,7 +116,10 @@ gocardless_service.create expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) end context "with no payment provider" do @@ -232,4 +235,147 @@ end end end + + describe "#update_payment_status" do + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: provider_payment_id, + status: "pending_submission" + ) + end + + let(:provider_payment_id) { "ch_123456" } + + before do + allow(SendWebhookJob).to receive(:perform_later) + payment + end + + it "updates the payment, payment_request and invoice payment_status", :aggregate_failures do + result = gocardless_service.update_payment_status( + provider_payment_id:, + status: "paid_out" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("paid_out") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + + context "when status is failed" do + it "updates the payment, payment_request and invoice status", :aggregate_failures do + result = gocardless_service.update_payment_status( + provider_payment_id:, + status: "failed" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + end + end + + context "when payment is not found" do + let(:payment) { nil } + + it "returns a not found error", :aggregate_failures do + result = gocardless_service.update_payment_status( + provider_payment_id:, + status: "paid_out" + ) + + expect(result).not_to be_success + expect(result.payment).to be_nil + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("gocardless_payment_not_found") + end + end + + context "when payment_request and invoice is already payment_succeeded" do + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoice, payment_request and payment" do + expect { + gocardless_service.update_payment_status(provider_payment_id:, status: "paid_out") + }.to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + result = gocardless_service.update_payment_status(provider_payment_id:, status: "paid_out") + + expect(result).to be_success + end + end + + context "with invalid status", :aggregate_failures do + let(:status) { "invalid-status" } + + it "does not update the payment_status of payment_request, invoice and payment" do + expect { + gocardless_service.update_payment_status(provider_payment_id:, status:) + }.to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and change { payment.reload.status }.to(status) + end + + it "returns an error", :aggregate_failures do + result = gocardless_service.update_payment_status(provider_payment_id:, status:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payment_status) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + end + + context "when payment request is not passed to constructor" do + subject(:gocardless_service) { described_class.new(nil) } + + before do + payment_request + end + + it "updates the payment and invoice payment_status" do + result = gocardless_service.update_payment_status( + provider_payment_id:, + status: "paid_out" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("paid_out") + + expect(result.payable).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + end + end end diff --git a/spec/services/payment_requests/update_service_spec.rb b/spec/services/payment_requests/update_service_spec.rb new file mode 100644 index 00000000000..193c67d22ca --- /dev/null +++ b/spec/services/payment_requests/update_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::UpdateService do + subject(:result) do + described_class.call( + payable: payment_request, + params: update_args, + webhook_notification: + ) + end + + let(:payment_request) { create :payment_request } + let(:webhook_notification) { false } + let(:update_args) { {payment_status: "succeeded"} } + + describe "#call" do + before do + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "updates the invoice", :aggregate_failures do + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payable).to be_payment_succeeded + end + + it "calls SegmentTrackJob" do + result + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "payment_status_changed", + properties: { + organization_id: payment_request.organization.id, + payment_request_id: payment_request.id, + payment_status: payment_request.payment_status + } + ) + end + + context "when payment_request does not exist" do + let(:payment_request) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("payment_request_not_found") + end + end + end +end diff --git a/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb b/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb new file mode 100644 index 00000000000..c3d43254846 --- /dev/null +++ b/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentRequests::PaymentStatusUpdatedService do + subject(:webhook_service) { described_class.new(object: payment_request) } + + let(:payment_request) { create(:payment_request) } + + describe ".call" do + it_behaves_like "creates webhook", "payment_request.payment_status_updated", "payment_request" + end +end