feat(payment-gated-subs): add PSP payment cancel dispatcher#5514
Merged
ancorcruz merged 4 commits intoMay 26, 2026
Conversation
c64c41d to
a478d74
Compare
osmarluz
approved these changes
May 19, 2026
Contributor
osmarluz
left a comment
There was a problem hiding this comment.
Looks good, just left 2 suggestions
ea05c77 to
2054884
Compare
## 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.
…ueue 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.
2054884 to
ef92d7e
Compare
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
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:
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:
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.