diff --git a/app/services/payment_providers/gocardless/payments/cancel_service.rb b/app/services/payment_providers/gocardless/payments/cancel_service.rb new file mode 100644 index 00000000000..06249b27f38 --- /dev/null +++ b/app/services/payment_providers/gocardless/payments/cancel_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + module Payments + class CancelService < BaseService + Result = BaseResult[:payment] + + def initialize(payment:) + @payment = payment + super + end + + def call + gocardless_result = client.payments.cancel(payment.provider_payment_id) + + payment.status = gocardless_result.status + payment.payable_payment_status = payment.payment_provider.determine_payment_status(payment.status) + payment.save! + + result.payment = payment + result + rescue GoCardlessPro::InvalidStateError => e + # Best-effort cancel only for the documented "cancellation_failed" + # case — the payment is in a state that cannot be cancelled + # (already submitted, paid out, cancelled, etc.). Log and treat as + # a successful no-op so the caller (timeout/expiration flow) does + # not block on PSP-side cleanup. The Payment record is left + # untouched; the webhook for the prior state transition will land + # its true state. + # + # Other InvalidStateError codes propagate so the caller can retry + # or surface the failure. + raise unless e.code == "cancellation_failed" + + Rails.logger.info("GoCardless payment not cancelable for payment #{payment.id}: #{e.message}") + result.payment = payment + result + rescue Faraday::ConnectionFailed => e + # GoCardless gem surfaces transport errors as raw Faraday + # exceptions. Wrap so the caller can retry through the same path + # as other PSPs (matches the create-side error handling pattern). + raise Invoices::Payments::ConnectionError, e + end + + private + + attr_reader :payment + + def client + @client ||= GoCardlessPro::Client.new( + access_token: payment.payment_provider.access_token, + environment: payment.payment_provider.environment + ) + end + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/payments/cancel_service_spec.rb b/spec/services/payment_providers/gocardless/payments/cancel_service_spec.rb new file mode 100644 index 00000000000..043fb82988f --- /dev/null +++ b/spec/services/payment_providers/gocardless/payments/cancel_service_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::Payments::CancelService do + subject(:result) { described_class.call(payment:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment_provider) { create(:gocardless_provider, organization:, access_token: "gc_test_token") } + 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, status: "pending_submission") + end + + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_payments_service) { instance_double(GoCardlessPro::Services::PaymentsService) } + + before do + allow(GoCardlessPro::Client).to receive(:new).and_return(gocardless_client) + allow(gocardless_client).to receive(:payments).and_return(gocardless_payments_service) + end + + context "when the payment is cancelable" do + let(:cancel_response) do + instance_double(GoCardlessPro::Resources::Payment, id: "PM123", status: "cancelled") + end + + before do + allow(gocardless_payments_service).to receive(:cancel).and_return(cancel_response) + end + + it "calls the GoCardless cancel endpoint with the payment's provider id" do + result + + expect(gocardless_payments_service).to have_received(:cancel).with("PM123") + end + + it "constructs the client with the provider's access token and environment" do + result + + expect(GoCardlessPro::Client).to have_received(:new).with( + access_token: "gc_test_token", + environment: payment_provider.environment + ) + end + + it "returns a successful result with the payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + end + + it "writes the cancelled status from the response onto the payment" do + result + + expect(payment.reload.status).to eq("cancelled") + end + + it "maps the cancelled status to a failed payable_payment_status" do + result + + expect(payment.reload.payable_payment_status).to eq("failed") + end + end + + context "when GoCardless raises InvalidStateError with code cancellation_failed" do + before do + allow(gocardless_payments_service).to receive(:cancel) + .and_raise(GoCardlessPro::InvalidStateError.new( + "message" => "This payment cannot be cancelled, its status is submitted", + "code" => "cancellation_failed" + )) + end + + it "returns a successful result with the payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + end + + it "logs the underlying error message" do + allow(Rails.logger).to receive(:info) + + result + + expect(Rails.logger).to have_received(:info) + .with(a_string_matching(/GoCardless.*not cancelable.*status is submitted/)) + end + + it "does not mutate the payment record" do + expect { result }.not_to change { payment.reload.attributes } + end + end + + context "when GoCardless raises InvalidStateError with a different code" do + before do + allow(gocardless_payments_service).to receive(:cancel) + .and_raise(GoCardlessPro::InvalidStateError.new( + "message" => "Mandate is inactive", + "code" => "mandate_is_inactive" + )) + end + + it "propagates the error so the caller can retry or surface the failure" do + expect { result }.to raise_error(GoCardlessPro::InvalidStateError) + end + end + + context "when GoCardless raises a generic error" do + before do + allow(gocardless_payments_service).to receive(:cancel) + .and_raise(GoCardlessPro::Error.new( + "message" => "Internal server error", + "code" => "server_error" + )) + end + + it "propagates the error so the caller can retry" do + expect { result }.to raise_error(GoCardlessPro::Error) + end + end + + context "when a Faraday connection failure occurs" do + before do + allow(gocardless_payments_service).to receive(:cancel) + .and_raise(Faraday::ConnectionFailed.new("connection refused")) + end + + it "wraps the error as Invoices::Payments::ConnectionError so the caller can retry" do + expect { result }.to raise_error(Invoices::Payments::ConnectionError) + end + end +end