Repository navigation
Conversation
There was a problem hiding this comment.
-
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 oncompleted.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.
GSadee
left a comment
There was a problem hiding this comment.
Suggested flow, so the payment only becomes refunded once PayPal has returned the money, and it still works with an async transport:
-
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.
-
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
-
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.
-
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.
c80c73a to
aa64b99
Compare
|
Thanks, I've reworked the PR to follow the suggested flow:
The late capture refund now goes through a 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? |
GSadee
left a comment
There was a problem hiding this comment.
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.
| 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']); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
| public function refund(PaymentInterface $data): PaymentInterface; | |
| public function refund(PaymentInterface $payment): PaymentInterface; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
| final class RefundPayPalCapturePaymentRequest implements PaymentRequestHashAwareInterface | |
| final class RefundPayPalPaymentRequest implements PaymentRequestHashAwareInterface |
🤔
There was a problem hiding this comment.
Agreed, it refunds the whole payment. Renamed in #829, together with the handler, the dispatcher and their service ids.
| /** @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; | ||
| } |
There was a problem hiding this comment.
I believe it should be checked before changing the state to processing 🤔
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
You are not checking the factory name here
There was a problem hiding this comment.
Added in #826, the same check as in the late capture handler.
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}/refundwere 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)refundPayment Request and dispatching it. The handler refunds the capture with the request hash asPayPal-Request-Id: a completed refund is stored in the payment details before therefundtransition is applied, a pending one keeps the request processing until thePAYMENT.CAPTURE.REFUNDEDwebhook confirms it, and a rejected one fails the request with PayPal's reason while the payment stays completed. A guard blocks therefundtransition 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 ownrefund_late_capturePayment Request.