Skip to content

feat(payment-gated-subs): add PSP payment cancel dispatcher#5514

Merged
ancorcruz merged 4 commits into
mainfrom
feat/payment-gated-subs-2/payments-cancel-dispatcher
May 26, 2026
Merged

feat(payment-gated-subs): add PSP payment cancel dispatcher#5514
ancorcruz merged 4 commits into
mainfrom
feat/payment-gated-subs-2/payments-cancel-dispatcher

Conversation

@ancorcruz
Copy link
Copy Markdown
Contributor

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.

@ancorcruz ancorcruz requested a review from osmarluz May 15, 2026 10:46
@ancorcruz ancorcruz self-assigned this May 15, 2026
@ancorcruz ancorcruz marked this pull request as ready for review May 15, 2026 11:30
@ancorcruz ancorcruz force-pushed the feat/payment-gated-subs-2/payments-cancel-dispatcher branch from c64c41d to a478d74 Compare May 19, 2026 12:34
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.

Looks good, just left 2 suggestions

Comment thread app/services/payment_providers/cancel_payment_service.rb Outdated
Comment thread app/services/payment_providers/cancel_payment_service.rb
@ancorcruz ancorcruz force-pushed the feat/payment-gated-subs-2/payments-cancel-dispatcher branch from ea05c77 to 2054884 Compare May 26, 2026 12:09
ancorcruz added 4 commits May 26, 2026 14:33
 ## 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.
@ancorcruz ancorcruz force-pushed the feat/payment-gated-subs-2/payments-cancel-dispatcher branch from 2054884 to ef92d7e Compare May 26, 2026 13:33
@ancorcruz ancorcruz merged commit 254497c into main May 26, 2026
12 checks passed
@ancorcruz ancorcruz deleted the feat/payment-gated-subs-2/payments-cancel-dispatcher branch May 26, 2026 13:52
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