feat(payment-gated-subs): add GoCardless payment cancel service#5484
Merged
ancorcruz merged 3 commits intoMay 19, 2026
Conversation
## 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.
…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
Contributor
Author
|
tested against GoCardless test enviroment with script... |
osmarluz
approved these changes
May 18, 2026
Contributor
osmarluz
left a comment
There was a problem hiding this comment.
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.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'spayments.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 topayable_payment_statusvia the provider's status mapping ("cancelled"is inFAILED_STATUSES, so the local lifecycle status becomesfailed). The payment is exposed on the result viaResult = BaseResult[:payment].Error handling
Matches the same shape applied to the Stripe and Adyen cancel leaves:
GoCardlessPro::InvalidStateErrorwith 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::InvalidStateErrorwith any other code propagates so the caller can retry or surface the failure.GoCardlessPro::Errorsubclasses propagate.Faraday::ConnectionFailedis wrapped asInvoices::Payments::ConnectionErrorso transport failures flow through the same retry pathway as other PSP services.Webhook flow (already wired)
PaymentProviders::Gocardless::HandleEventServiceroutes the"cancelled"payment action through the existingupdate_payment_statuspipeline, 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.