Skip to content

Run admin refunds through Payment Requests - #812

Merged
GSadee merged 3 commits into
Sylius:phase-2-payment-requestsfrom
rust-le:feature/SY-884-admin-refund-payment-requests
Oct 19, 2026
Merged

GSadee merged 3 commits into
Sylius:phase-2-payment-requestsfrom
rust-le:feature/SY-884-admin-refund-payment-requests

Conversation

@rust-le

@rust-le rust-le commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Branch? phase-2-payment-requests
Bug fix? yes
New feature? no
BC breaks? yes, see UPGRADE-3.0.md
Related tickets SY-884

Refunding a PayPal payment from the admin called PayPal from a payment state machine callback, so the payment was marked as refunded inside the same transition that asked PayPal for the money. A refund PayPal rejected either rolled the whole transition back, losing any trace of the attempt, or, in the admin API, could leave the payment refunded without a refund in PayPal. The core "Refund" button and PATCH /admin/payments/{id}/refund were the only way in, and the late capture refund called PayPal directly as well.

The admin now refunds a PayPal payment with a PayPal "Refund" button and PATCH /admin/payments/{id}/paypal-refund, both creating (or reusing the open) refund Payment Request and dispatching it. The handler refunds the capture with the request hash as PayPal-Request-Id: a completed refund is stored in the payment details before the refund transition is applied, a pending one keeps the request processing until the PAYMENT.CAPTURE.REFUNDED webhook confirms it, and a rejected one fails the request with PayPal's reason while the payment stays completed. A guard blocks the refund transition of a PayPal payment without a confirmed refund, so the core button and endpoint can no longer skip PayPal. The late capture refund goes through its own refund_late_capture Payment Request.

@GSadee GSadee left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  • The handler and the PayPal error reporting look good. I'd like to change the flow to match the Stripe plugin and the SY-884 description: run the listener on completed.refund, create/announce the refund Payment Request and never throw. The refund then works with an async payment request transport, every attempt (including failed ones) stays in the database.

  • The late-capture refund seems to be left out: RefundPayPalLateCaptureAction still calls PayPal directly through PayPalLateCaptureRefundProcessor.

Comment thread config/services/listeners/workflow.php Outdated
Comment thread config/integrations/winzou_state_machine/winzou_state_machine/sylius_payment.yaml Outdated
Comment thread src/Processor/PaymentTransitionPaymentRequestProcessor.php Outdated
@rust-le rust-le changed the title Run admin refunds through Payment Requests cRun admin refunds through Payment Requests Oct 14, 2026
@rust-le rust-le changed the title cRun admin refunds through Payment Requests [WIP] Run admin refunds through Payment Requests Oct 14, 2026
@GSadee
GSadee marked this pull request as draft October 14, 2026 07:48

@GSadee GSadee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested flow, so the payment only becomes refunded once PayPal has returned the money, and it still works with an async transport:

  1. A PayPal-specific "Refund" action in the admin (twig hook, like the late-capture refund) and an admin operation create a refund Payment Request (or reuse an open one) and announce it. Neither applies the refund transition.

  2. The handler calls PayPal with PayPal-Request-Id = the request hash:

  • COMPLETED: store the refund in the payment details, apply refund to the payment, complete the request
  • PENDING: mark the request processing; the webhook finishes it
  • FAILED/CANCELLED/error: fail the request with the reason; the payment stays completed and the admin can retry
  1. A guard on refund blocks PayPal payments without a confirmed PayPal refund, so the core button and PATCH /admin/payments/{id}/refund can't skip PayPal.

  2. The PAYMENT.CAPTURE.REFUNDED webhook stores the confirmation before applying refund and completes an open processing request.

The handler, command and provider from this PR stay. The transition.refund listener, the throwing processor and the UI decorator go away.

@rust-le
rust-le force-pushed the feature/SY-884-admin-refund-payment-requests branch from c80c73a to aa64b99 Compare October 16, 2026 13:31
@rust-le rust-le changed the title [WIP] Run admin refunds through Payment Requests Run admin refunds through Payment Requests Oct 16, 2026
@rust-le

rust-le commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I've reworked the PR to follow the suggested flow:

  1. The PayPal "Refund" button (twig hook next to the late capture one) and PATCH /admin/payments/{id}/paypal-refund create or reuse the open refund Payment Request and dispatch it, neither applies the refund transition.
  2. The handler sends the request hash as PayPal-Request-Id. COMPLETED stores refunded/refund_id in the payment details, applies refund and completes the request, PENDING keeps it processing, FAILED/CANCELLED/an API error fails it with the reason and the payment stays completed. Retrying a pending refund reuses the same request, so PayPal returns the same refund.
  3. A guard on refund (workflow listener and winzou guard callback) blocks PayPal payments without a confirmed refund, the core button is hidden and PATCH /admin/payments/{id}/refund answers 422.
  4. The PAYMENT.CAPTURE.REFUNDED webhook (current endpoint) stores the confirmation through PayPalPaymentRefundConfirmationProcessorInterface before applying refund and completes an open processing refund request. Process PayPal webhook events received on the notify endpoint #819 will call the same service from RefundPaymentWebhookEventProcessor.

The late capture refund now goes through a refund_late_capture Payment Request, with its own provider and handler, so it doesn't mix with the payment refund in the guard, the webhook and the request reuse. PayPalLateCaptureRefundProcessor, PaymentRefundProcessorInterface and PayPalOrderRefundException are removed.

One question: a partial refund made in the PayPal dashboard still marks the whole payment as refunded through the webhook, as before. Should we keep it like that for now?

@rust-le
rust-le marked this pull request as ready for review October 16, 2026 13:37
@GSadee GSadee added Feature Admin Admin Panel related issues and PRs. labels Oct 19, 2026

@GSadee GSadee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Regarding partial refunds, yes, for now let's leave it as it is. We will probably only add full support for partial refunds once we've integrated with the Refund Plugin.

Comment on lines +129 to +135
if (!is_string($refund['id'] ?? null) || in_array($refund['status'] ?? null, self::FAILED_REFUND_STATUSES, true)) {
$this->failWithReason($paymentRequest, sprintf('PayPal reported the refund as %s.', is_string($refund['status'] ?? null) ? $refund['status'] : 'unknown'));

return;
}

$this->complete($paymentRequest, $payment, $details, $lateCapture, $refund['id']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the returned status is PENDING, the refund will be marked as refunded and this is different from how a standard refund works. I’m wondering if this is intentional, given the different handling via the webhook?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The webhook also marked the late capture as refunded but never completed the open refund_late_capture request. Fixed in #827: PENDING keeps the request processing, and the new PayPalLateCaptureRefundConfirmationProcessor, used by both the handler and the webhook, records the refund and completes the request.

{
parent::__construct('Could not refund PayPal order');
}
public function refund(PaymentInterface $data): PaymentInterface;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
public function refund(PaymentInterface $data): PaymentInterface;
public function refund(PaymentInterface $payment): PaymentInterface;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

refund() is the API Platform controller of PATCH /admin/payments/{id}/paypal-refund. With input="false" the operation doesn't deserialize, so PayloadArgumentResolver skips it and the payment only reaches the method through the data request attribute, resolved by the argument name. Renaming it to $payment makes the endpoint fail with "Could not resolve argument $payment". Core keeps $data for the same reason in PaymentStateMachineTransitionApplicatorInterface::refund(PaymentInterface $data), so I'd leave it.

use Sylius\Bundle\PaymentBundle\Command\PaymentRequestHashAwareInterface;
use Sylius\Bundle\PaymentBundle\Command\PaymentRequestHashAwareTrait;

final class RefundPayPalCapturePaymentRequest implements PaymentRequestHashAwareInterface

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
final class RefundPayPalCapturePaymentRequest implements PaymentRequestHashAwareInterface
final class RefundPayPalPaymentRequest implements PaymentRequestHashAwareInterface

🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, it refunds the whole payment. Renamed in #829, together with the handler, the dispatcher and their service ids.

Comment on lines +66 to +72
/** @var PaymentMethodInterface $paymentMethod */
$paymentMethod = $paymentRequest->getMethod();
if (SyliusPayPalExtension::PAYPAL_FACTORY_NAME !== $paymentMethod->getGatewayConfig()?->getFactoryName()) {
$this->failWithReason($paymentRequest, 'The payment was not paid with PayPal.');

return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe it should be checked before changing the state to processing 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #826: the gateway is checked before the process transition, and a request of another gateway is failed straight from new.

$this->stateMachine = $stateMachine;
}

public function __invoke(RefundPayPalCapturePaymentRequest $refundPayPalCapturePaymentRequest): void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You are not checking the factory name here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in #826, the same check as in the late capture handler.

@GSadee
GSadee merged commit 8bdda7f into Sylius:phase-2-payment-requests Oct 19, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Admin Admin Panel related issues and PRs. Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants