From 858e7bd16622ebe9c09f4db433d662094b966b3c Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 10:32:44 +0100 Subject: [PATCH 1/4] feat(payment-gated-subs): add PSP payment cancel dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The timeout/expiration flow for incomplete payment-gated subscriptions needs to release the open PSP authorization on a best-effort basis. Each supported PSP (Stripe, Adyen, GoCardless) has its own cancel service; we need a single entry point that routes a Payment to the right one without callers needing to know which provider is in play. ## Description Add PaymentProviders::CancelPaymentService — a dispatcher that takes a payment and routes by payment.payment_provider.type: - PaymentProviders::StripeProvider -> Stripe::Payments::CancelService - PaymentProviders::AdyenProvider -> Adyen::Payments::CancelService - PaymentProviders::GocardlessProvider -> Gocardless::Payments::CancelService - Other providers (Cashfree, Flutterwave, MoneyHash): silent no-op with an info log. These PSPs do not expose a cancel API; the eventual webhook (or manual reconciliation) is the lifecycle authority. Three short-circuit guards return a successful no-op with the payment exposed on the result: - payment.payment_provider.blank? - payment.provider_payment_id.blank? - payment.payable_payment_status == "succeeded" The "succeeded" guard is protective: canceling a captured payment would either error at the PSP or, depending on provider, trigger an unintended refund. The dispatcher is best-effort by contract — these guards keep callers from needing pre-checks. PaymentProviders::CancelPaymentJob is the async wrapper. It takes a payment and forwards to the dispatcher; the caller (the upcoming ExpireService) is responsible for picking the right payment from whatever scope it has. Placement follows the existing convention. The create-side dispatcher PaymentProviders::CreatePaymentFactory lives directly under PaymentProviders::; this dispatcher sits alongside it. Service rather than Factory because the cancel services share a uniform payment: signature, so the Factory.new_instance().call! two-step is unnecessary. --- .../payment_providers/cancel_payment_job.rb | 11 ++ .../cancel_payment_service.rb | 43 +++++ .../cancel_payment_job_spec.rb | 24 +++ .../cancel_payment_service_spec.rb | 170 ++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 app/jobs/payment_providers/cancel_payment_job.rb create mode 100644 app/services/payment_providers/cancel_payment_service.rb create mode 100644 spec/jobs/payment_providers/cancel_payment_job_spec.rb create mode 100644 spec/services/payment_providers/cancel_payment_service_spec.rb 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..15cf8e87339 --- /dev/null +++ b/app/jobs/payment_providers/cancel_payment_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PaymentProviders + class CancelPaymentJob < ApplicationJob + queue_as "default" + + 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..6c61862a4f3 --- /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.payable_payment_status == "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..502bfbc144f --- /dev/null +++ b/spec/services/payment_providers/cancel_payment_service_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CancelPaymentService do + # TODO: remove after PRs adding these classes are merged... + class PaymentProviders::Adyen::Payments::CancelService < BaseService; end + class PaymentProviders::Gocardless::Payments::CancelService < BaseService; end + + 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 From d05cadc956d628c0d0eedb9e4ae303bf3e8522b9 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 12:13:18 +0100 Subject: [PATCH 2/4] refactor(jobs): route cancel payment job through payments/providers queue The job was using the generic "default" queue. Sibling jobs in the PaymentProviders namespace (CancelPaymentAuthorizationJob in particular) follow a conditional queue pattern: :payments when SIDEKIQ_PAYMENTS is enabled, :providers otherwise. This keeps PSP- touching background work off the general queue when a deployment has configured a dedicated payments worker pool, and on the providers queue when it has not. Matches the convention so an operator can scale these jobs together. --- app/jobs/payment_providers/cancel_payment_job.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/jobs/payment_providers/cancel_payment_job.rb b/app/jobs/payment_providers/cancel_payment_job.rb index 15cf8e87339..0422fede5c6 100644 --- a/app/jobs/payment_providers/cancel_payment_job.rb +++ b/app/jobs/payment_providers/cancel_payment_job.rb @@ -2,7 +2,13 @@ module PaymentProviders class CancelPaymentJob < ApplicationJob - queue_as "default" + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end def perform(payment) PaymentProviders::CancelPaymentService.call!(payment:) From 96e434a18bd19981c3f12506a4434d08157ea149 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 19 May 2026 13:32:20 +0100 Subject: [PATCH 3/4] remove faked classes from specs --- .../services/payment_providers/cancel_payment_service_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/services/payment_providers/cancel_payment_service_spec.rb b/spec/services/payment_providers/cancel_payment_service_spec.rb index 502bfbc144f..a5fd8d8c1ac 100644 --- a/spec/services/payment_providers/cancel_payment_service_spec.rb +++ b/spec/services/payment_providers/cancel_payment_service_spec.rb @@ -3,10 +3,6 @@ require "rails_helper" RSpec.describe PaymentProviders::CancelPaymentService do - # TODO: remove after PRs adding these classes are merged... - class PaymentProviders::Adyen::Payments::CancelService < BaseService; end - class PaymentProviders::Gocardless::Payments::CancelService < BaseService; end - subject(:result) { described_class.call(payment:) } let(:organization) { create(:organization) } From ef92d7ea2e317ce5a1ce5dd861cddc5e42001819 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 26 May 2026 12:25:31 +0100 Subject: [PATCH 4/4] Use enum magic method instead of matching value --- app/services/payment_providers/cancel_payment_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/payment_providers/cancel_payment_service.rb b/app/services/payment_providers/cancel_payment_service.rb index 6c61862a4f3..fa545389777 100644 --- a/app/services/payment_providers/cancel_payment_service.rb +++ b/app/services/payment_providers/cancel_payment_service.rb @@ -14,7 +14,7 @@ def call return result if payment.payment_provider.blank? return result if payment.provider_payment_id.blank? - return result if payment.payable_payment_status == "succeeded" + return result if payment.succeeded? case payment.payment_provider.type when "PaymentProviders::StripeProvider"