Skip to content

feat(payment-gated-subs): add GoCardless payment cancel service#5484

Merged
ancorcruz merged 3 commits into
mainfrom
feat/payment-gated-subs-2/gocardless-payment-cancel
May 19, 2026
Merged

feat(payment-gated-subs): add GoCardless payment cancel service#5484
ancorcruz merged 3 commits into
mainfrom
feat/payment-gated-subs-2/gocardless-payment-cancel

Conversation

@ancorcruz
Copy link
Copy Markdown
Contributor

@ancorcruz ancorcruz commented May 11, 2026

Context

Payment gated subscriptions need a way to release a pending GoCardless authorization when activation times out without the customer completing payment, so funds are not held indefinitely on the PSP side.

Description

Add PaymentProviders::Gocardless::Payments::CancelService. It calls GoCardless's payments.cancel(id) action. The sync response returns the updated Payment resource with status "cancelled", so the service mirrors the create-side pattern: write the response status onto the Payment record and map it to payable_payment_status via the provider's status mapping ("cancelled" is in FAILED_STATUSES, so the local lifecycle status becomes failed). The payment is exposed on the result via Result = BaseResult[:payment].

Error handling

Matches the same shape applied to the Stripe and Adyen cancel leaves:

  • GoCardlessPro::InvalidStateError with code "cancellation_failed" is treated as a best-effort no-op — the payment is in a state that cannot be cancelled (already submitted, paid out, cancelled, etc.), the caller (timeout/expiration flow) should not block on PSP-side cleanup, and the Payment record is left untouched so its real lifecycle state remains intact. The webhook for the prior state transition is the right authority for that state.
  • GoCardlessPro::InvalidStateError with any other code propagates so the caller can retry or surface the failure.
  • Other GoCardlessPro::Error subclasses propagate.
  • Faraday::ConnectionFailed is wrapped as Invoices::Payments::ConnectionError so transport failures flow through the same retry pathway as other PSP services.

Webhook flow (already wired)

PaymentProviders::Gocardless::HandleEventService routes the "cancelled" payment action through the existing update_payment_status pipeline, so the asynchronous confirmation of the cancel reaches the Payment record through the same path used by every other payment state change. No changes needed here.

## Context

Payment-gated subscriptions need a way to release a pending GoCardless
authorization when activation times out without the customer
completing payment, so funds are not held indefinitely on the PSP
side.

## Description

Add PaymentProviders::Gocardless::Payments::CancelService, a leaf that
calls GoCardless's payments.cancel(id) action. The sync response
returns the updated Payment resource with status "cancelled", so the
service mirrors the create-side pattern: write the response status
onto the Payment record and map it to payable_payment_status via the
provider's status mapping (cancelled is in FAILED_STATUSES, so the
local lifecycle status becomes failed). Expose the payment on the
result via Result = BaseResult[:payment].

GoCardlessPro::InvalidStateError — raised when the payment is in a
state that cannot be cancelled (already submitted, paid out,
cancelled, etc.) — is rescued and treated as a successful no-op so
the caller (timeout/expiration flow) does not block on PSP-side
cleanup. The Payment record is left untouched in that case; the
webhook for the prior state transition is the right authority for
that state. Other GoCardlessPro::Error subclasses propagate so the
caller can retry.

The CANCELLATION webhook flow is already wired:
PaymentProviders::Gocardless::HandleEventService routes the
"cancelled" payment action through the existing update_payment_status
pipeline, so the asynchronous confirmation of the cancel reaches the
Payment record through the same path used by every other payment
state change.
@ancorcruz ancorcruz self-assigned this May 11, 2026
…rors

Aligns the GoCardless cancel leaf with the same error-handling shape
applied to the Stripe and Adyen leaves:

- Narrow the GoCardlessPro::InvalidStateError rescue to only swallow
  code "cancellation_failed" (the documented "payment is in a state
  that cannot be cancelled" case). Other InvalidStateError codes
  propagate so the caller can retry or surface the failure.

- Wrap Faraday::ConnectionFailed as Invoices::Payments::ConnectionError.
  The GoCardless gem surfaces transport errors as raw Faraday
  exceptions; wrapping matches the framework's retry pathway used by
  other PSP services.

Spec covers all four paths:
- cancelable: writes status to Payment, returns success
- InvalidStateError with code "cancellation_failed": swallowed
- InvalidStateError with a different code: propagates
- Generic GoCardlessPro::Error: propagates
- Faraday::ConnectionFailed: wrapped as Invoices::Payments::ConnectionError
@ancorcruz
Copy link
Copy Markdown
Contributor Author

ancorcruz commented May 14, 2026

tested against GoCardless test enviroment with script...

# frozen_string_literal: true

# QA script for PaymentProviders::Gocardless::Payments::CancelService against
# the real GoCardless API (sandbox), and PaymentProviders::Gocardless::HandleEventService
# against a synthetic payment.cancelled event.
#
# Usage:
#   GOCARDLESS_ACCESS_TOKEN=sandbox_xxx \
#     lago exec api bin/rails runner bin/qa_gocardless_cancel.rb
#
# Optional:
#   GOCARDLESS_MANDATE_ID=MD000XXX   (reuse an existing sandbox mandate;
#                                     otherwise the script creates one)
#
# What it does:
#   1. Reuses an existing dev Organization (with a default billing entity) and
#      a Customer on it (creates one if none exists). Creates the disposable
#      bits we need (GocardlessProvider, GocardlessCustomer, Invoice, Payment).
#   2. Test 1 — leaf happy path:
#        a) Authorise a payment via GoCardless's real sandbox API.
#        b) Call the CancelService → expects 200 with status "cancelled".
#        c) Verifies the Lago Payment row flipped to status "cancelled" and
#           payable_payment_status "failed" (symmetric write from the response).
#   3. Test 2 — leaf rescue path:
#        a) Try to cancel the same payment again → GoCardless raises
#           InvalidStateError (cancellation_failed).
#        b) Service returns success without raising; Payment row untouched.
#   4. Test 3 — webhook flow:
#        Synthesize a payment.cancelled event payload for a fresh local
#        Payment and feed it through HandleEventService.call! → expects the
#        Payment row to flip from :pending to :failed.
#   5. Cleans up local DB rows. GoCardless test payments and mandates linger
#      in the sandbox; clean them up there manually if you care.

ACCESS_TOKEN = ENV.fetch("GOCARDLESS_ACCESS_TOKEN") do
  # abort "GOCARDLESS_ACCESS_TOKEN env var is required (use a sandbox access token)."
end

require "factory_bot_rails"
FactoryBot.find_definitions if FactoryBot.factories.none?
include FactoryBot::Syntax::Methods

def banner(msg)
  puts "\n#{"=" * 60}\n#{msg}\n#{"=" * 60}"
end

def assert(condition, expected, actual)
  if condition
    puts "  ✓ #{expected}"
  else
    puts "  ✗ EXPECTED: #{expected}"
    puts "    ACTUAL:   #{actual}"
  end
end

def run_test(label)
  banner label
  yield
rescue => e
  puts "  ✗ TEST RAISED: #{e.class} #{e.message}"
  puts "    #{e.backtrace.first(5).join("\n    ")}"
end

def gocardless_client
  @gocardless_client ||= GoCardlessPro::Client.new(access_token: ACCESS_TOKEN, environment: :sandbox)
end

# Creates a sandbox customer + bank account + mandate. GoCardless sandbox
# transitions these to a usable state immediately, so payments created against
# the returned mandate land in pending_submission and are cancellable.
def create_sandbox_mandate
  customer = gocardless_client.customers.create(params: {
    given_name: "Lago",
    family_name: "QA",
    email: "lago-qa-#{SecureRandom.hex(4)}@example.com",
    country_code: "GB"
  })

  bank_account = gocardless_client.customer_bank_accounts.create(params: {
    account_holder_name: "Lago QA",
    account_number: "55779911",
    branch_code: "200000",
    country_code: "GB",
    currency: "GBP",
    links: {customer: customer.id}
  })

  mandate = gocardless_client.mandates.create(params: {
    scheme: "bacs",
    links: {customer_bank_account: bank_account.id}
  })

  puts "  created sandbox mandate=#{mandate.id} status=#{mandate.status}"
  mandate.id
end

def create_gocardless_payment(mandate_id)
  gocardless_client.payments.create(params: {
    amount: 1000,
    currency: "GBP",
    links: {mandate: mandate_id},
    metadata: {lago_qa: "true"}
  })
end

banner "Setting up disposable test fixtures"

organization = if ENV["QA_ORG_ID"]
  Organization.find(ENV["QA_ORG_ID"])
else
  Organization.joins(:billing_entities).where.not(billing_entities: {id: nil}).first
end
abort "No organization with a default billing entity — seed the dev DB first." unless organization

customer_was_created_for_qa = false
customer = if ENV["QA_CUSTOMER_ID"]
  Customer.find(ENV["QA_CUSTOMER_ID"])
elsif (existing = organization.customers.first)
  existing
else
  customer_was_created_for_qa = true
  create(:customer, organization:, billing_entity: organization.default_billing_entity)
end

mandate_id = ENV["GOCARDLESS_MANDATE_ID"] || create_sandbox_mandate

provider = create(:gocardless_provider, organization:, access_token: ACCESS_TOKEN)
provider_customer = create(:gocardless_customer, customer:, payment_provider: provider,
  provider_mandate_id: mandate_id)
invoice = create(:invoice, organization:, customer:, currency: "GBP",
  status: :open, invoice_type: :subscription)

disposables = [provider_customer, provider, invoice]
disposables << customer if customer_was_created_for_qa

puts "  organization=#{organization.id} (existing)"
puts "  customer=#{customer.id} (#{customer_was_created_for_qa ? "created" : "existing"})"
puts "  provider=#{provider.id} (created)"
puts "  invoice=#{invoice.id} (created)"
puts "  mandate=#{mandate_id}"

run_test "Test 1 — pending payment → leaf happy path" do
  gc_payment = create_gocardless_payment(mandate_id)
  puts "  created payment id=#{gc_payment.id} status=#{gc_payment.status}"

  payment = create(:payment, payable: invoice, payment_provider: provider,
    payment_provider_customer: provider_customer, organization:, customer:,
    provider_payment_id: gc_payment.id, payable_payment_status: :pending,
    status: gc_payment.status, amount_cents: gc_payment.amount, amount_currency: gc_payment.currency)

  result = PaymentProviders::Gocardless::Payments::CancelService.call!(payment:)
  assert result.success?, "service returned a successful result", result.error&.message

  payment.reload
  assert payment.status == "cancelled",
    "payment.status flipped to cancelled", payment.status
  assert payment.payable_payment_status == "failed",
    "payable_payment_status mapped to failed", payment.payable_payment_status
end

run_test "Test 2 — already cancelled → leaf rescue path" do
  gc_payment = create_gocardless_payment(mandate_id)
  puts "  created payment id=#{gc_payment.id} status=#{gc_payment.status}"

  payment = create(:payment, payable: invoice, payment_provider: provider,
    payment_provider_customer: provider_customer, organization:, customer:,
    provider_payment_id: gc_payment.id, payable_payment_status: :pending,
    status: gc_payment.status, amount_cents: gc_payment.amount, amount_currency: gc_payment.currency)

  # First cancel — accepted.
  PaymentProviders::Gocardless::Payments::CancelService.call!(payment:)

  # Second cancel — GoCardless rejects with InvalidStateError; service should
  # return success without raising.
  raised = false
  begin
    result = PaymentProviders::Gocardless::Payments::CancelService.call!(payment:)
    assert result.success?, "service returned a successful result on rescue path", result.error&.message
  rescue => e
    raised = true
    puts "  ✗ service raised: #{e.class} #{e.message}"
  end
  assert !raised, "service did not raise on already-cancelled payment",
    "service raised an exception"
end

run_test "Test 3 — synthetic payment.cancelled webhook → Payment row updates" do
  synthetic_psp_id = "PM_QA_#{SecureRandom.hex(6)}"

  payment = create(:payment, payable: invoice, payment_provider: provider,
    payment_provider_customer: provider_customer, organization:, customer:,
    provider_payment_id: synthetic_psp_id, payable_payment_status: :pending,
    status: "pending_submission")

  event_payload = {
    "id" => "EV_#{SecureRandom.hex(8)}",
    "resource_type" => "payments",
    "action" => "cancelled",
    "links" => {"payment" => synthetic_psp_id},
    "metadata" => {},
    "details" => {"origin" => "api", "cause" => "payment_cancelled"}
  }

  result = PaymentProviders::Gocardless::HandleEventService.call!(
    payment_provider: provider, event_json: event_payload.to_json
  )
  assert result.success?, "handle event service returned success", result.error&.message

  payment.reload
  assert payment.payable_payment_status == "failed",
    "payable_payment_status flipped to failed", payment.payable_payment_status
  assert payment.status == "cancelled",
    "raw status set to cancelled", payment.status
end

banner "Done — review output above. Look for ✓ on every line."

banner "Cleaning up disposable records"
Payment.where(payable: invoice).destroy_all
disposables.each(&:destroy)
puts "  cleanup complete. (GoCardless sandbox payments/mandates linger; clean up manually if needed.)"

@ancorcruz ancorcruz requested a review from osmarluz May 14, 2026 15:04
@ancorcruz ancorcruz marked this pull request as ready for review May 14, 2026 15:04
Copy link
Copy Markdown
Contributor

@osmarluz osmarluz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just left a minor comment

The InvalidStateError rescue branch returned a successful result but
did not set result.payment, so callers introspecting the rescue path
got nil. The happy path sets it correctly. This inconsistency was
caught in review (PR #5484).

Tighten the spec assertion in the rescue context from
"returns a successful result without raising" to
"returns a successful result with the payment" so the contract is
locked in and a future regression would fail the test.

(Stripe leaf has the same gap and will be addressed in a separate
small PR.)
@ancorcruz ancorcruz merged commit 67ed7f0 into main May 19, 2026
12 checks passed
@ancorcruz ancorcruz deleted the feat/payment-gated-subs-2/gocardless-payment-cancel branch May 19, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants