diff --git a/app/jobs/payment_providers/cancel_payment_job.rb b/app/jobs/payment_providers/cancel_payment_job.rb new file mode 100644 index 00000000000..0422fede5c6 --- /dev/null +++ b/app/jobs/payment_providers/cancel_payment_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentProviders + class CancelPaymentJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(payment) + PaymentProviders::CancelPaymentService.call!(payment:) + end + end +end diff --git a/app/services/payment_providers/cancel_payment_service.rb b/app/services/payment_providers/cancel_payment_service.rb new file mode 100644 index 00000000000..fa545389777 --- /dev/null +++ b/app/services/payment_providers/cancel_payment_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module PaymentProviders + class CancelPaymentService < BaseService + Result = BaseResult[:payment] + + def initialize(payment:) + @payment = payment + super + end + + def call + result.payment = payment + + return result if payment.payment_provider.blank? + return result if payment.provider_payment_id.blank? + return result if payment.succeeded? + + case payment.payment_provider.type + when "PaymentProviders::StripeProvider" + PaymentProviders::Stripe::Payments::CancelService.call!(payment:) + when "PaymentProviders::AdyenProvider" + PaymentProviders::Adyen::Payments::CancelService.call!(payment:) + when "PaymentProviders::GocardlessProvider" + PaymentProviders::Gocardless::Payments::CancelService.call!(payment:) + else + # Cashfree, Flutterwave, MoneyHash, and any future provider without a + # dedicated cancel service: nothing to do here. The eventual webhook + # (or reconciliation) is the lifecycle authority for the Payment. + Rails.logger.info( + "PaymentProviders::CancelPaymentService: PSP cancel not supported for " \ + "#{payment.payment_provider.type} (payment #{payment.id}); skipping" + ) + end + + result + end + + private + + attr_reader :payment + end +end diff --git a/spec/jobs/payment_providers/cancel_payment_job_spec.rb b/spec/jobs/payment_providers/cancel_payment_job_spec.rb new file mode 100644 index 00000000000..faab06b91c2 --- /dev/null +++ b/spec/jobs/payment_providers/cancel_payment_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CancelPaymentJob do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: "pi_123", payable_payment_status: :pending) + end + + before do + allow(PaymentProviders::CancelPaymentService).to receive(:call!) + end + + it "forwards the payment to the dispatcher service" do + described_class.perform_now(payment) + + expect(PaymentProviders::CancelPaymentService).to have_received(:call!).with(payment:) + end +end diff --git a/spec/services/payment_providers/cancel_payment_service_spec.rb b/spec/services/payment_providers/cancel_payment_service_spec.rb new file mode 100644 index 00000000000..a5fd8d8c1ac --- /dev/null +++ b/spec/services/payment_providers/cancel_payment_service_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CancelPaymentService do + subject(:result) { described_class.call(payment:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + before do + allow(PaymentProviders::Stripe::Payments::CancelService).to receive(:call!) + allow(PaymentProviders::Adyen::Payments::CancelService).to receive(:call!) + allow(PaymentProviders::Gocardless::Payments::CancelService).to receive(:call!) + end + + context "when payment has no payment provider" do + let(:payment) do + create(:payment, payable: invoice, payment_provider: nil, organization:, customer:, + provider_payment_id: "pi_123", payable_payment_status: :pending) + end + + it "returns a successful result with the payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + end + + it "does not call any PSP cancel service" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Adyen::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Gocardless::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when payment has no provider_payment_id" do + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: nil, payable_payment_status: :pending) + end + + it "returns a successful result with the payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + end + + it "does not call any PSP cancel service" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when payment is already succeeded" do + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: "pi_123", payable_payment_status: :succeeded) + end + + it "returns a successful result with the payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + end + + it "does not call the PSP cancel service (canceling a succeeded payment would be destructive)" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when the provider is Stripe" do + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: "pi_123", payable_payment_status: :pending) + end + + it "routes to the Stripe cancel service with the payment" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).to have_received(:call!).with(payment:) + end + + it "does not call the other PSP cancel services" do + result + + expect(PaymentProviders::Adyen::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Gocardless::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when the provider is Adyen" do + let(:payment_provider) { create(:adyen_provider, organization:) } + let(:provider_customer) { create(:adyen_customer, customer:, payment_provider:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, payment_provider_customer: provider_customer, + organization:, customer:, provider_payment_id: "PSPREF123", payable_payment_status: :pending) + end + + it "routes to the Adyen cancel service with the payment" do + result + + expect(PaymentProviders::Adyen::Payments::CancelService).to have_received(:call!).with(payment:) + end + + it "does not call the other PSP cancel services" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Gocardless::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when the provider is GoCardless" do + let(:payment_provider) { create(:gocardless_provider, organization:) } + let(:provider_customer) { create(:gocardless_customer, customer:, payment_provider:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, payment_provider_customer: provider_customer, + organization:, customer:, provider_payment_id: "PM123", payable_payment_status: :pending) + end + + it "routes to the GoCardless cancel service with the payment" do + result + + expect(PaymentProviders::Gocardless::Payments::CancelService).to have_received(:call!).with(payment:) + end + + it "does not call the other PSP cancel services" do + result + + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Adyen::Payments::CancelService).not_to have_received(:call!) + end + end + + context "when the provider has no dedicated cancel service (Cashfree, Flutterwave, MoneyHash)" do + let(:payment_provider) { create(:cashfree_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: "cf_123", payable_payment_status: :pending) + end + + it "returns a successful result without calling any PSP cancel service" do + result + + expect(result).to be_success + expect(PaymentProviders::Stripe::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Adyen::Payments::CancelService).not_to have_received(:call!) + expect(PaymentProviders::Gocardless::Payments::CancelService).not_to have_received(:call!) + end + + it "logs that the provider is unsupported" do + allow(Rails.logger).to receive(:info) + + result + + expect(Rails.logger).to have_received(:info) + .with(a_string_matching(/PSP cancel not supported.*CashfreeProvider/)) + end + end +end