PRE-3472: Fix error 500 when user proceed to return_cancel action on scalapay checkout#304
PRE-3472: Fix error 500 when user proceed to return_cancel action on scalapay checkout#304jhoaraupp wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent a 500 error during Scalapay “return_cancel” by making payment state transitions idempotent (checking can() before apply()), so already-transitioned payments don’t trigger invalid-transition exceptions.
Changes:
- Refactor payment status → transition mapping to compute a
$transitionfirst in both handlers. - Guard state-machine transitions with
StateMachineInterface::can()before callingapply(). - Add warning log + early return in the status handler when a transition can’t be applied.
1. What's Good
- Prevents invalid state-machine transition exceptions by using
can()checks beforeapply(). - Adds structured context to the Status handler log (payment id, current state, transition, status), which helps diagnose edge cases.
- Keeps the status→transition mapping centralized and easy to scan via
match.
2. Summary table
| Dimension | Rating |
|---|---|
| Security | ✅ Fine |
| Correctness | |
| Performance | ✅ Fine |
| Maintainability |
3. Closing one-liner
Address the forced-status persistence edge case (and improve observability when transitions are skipped) before merge.
4. Individual findings
Heading: Correctness
Subtitle (bold): Forced status can persist even when transition is skipped (StatusPaymentRequestHandler.php:109)
Code block:
if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
$this->logger->warning('[PayPlug] Cannot apply payment transition, payment is already in an incompatible state.', [
'payment_id' => $payment->getId(),
'current_state' => $payment->getState(),
'transition' => $transition,
'status' => $payment->getDetails()['status'] ?? '',
]);
return;
}Explanation paragraph: With forced status coming from the status query parameter, this early return prevents the exception (good), but it can also allow an overwritten details['status'] (e.g. canceled) to be persisted even though the Payment state remains completed. That creates a “details vs state” inconsistency that can confuse later logic that relies on details['status'].
Fix: Ensure forced status is only persisted when the transition is actually applied (e.g. move the forced-status write behind the can() check, or have updatePaymentState() return whether it applied and revert/skip the forced status when it didn’t).
Heading: Operational implications
Subtitle (bold): Log message is misleading when can() is false for idempotent calls (StatusPaymentRequestHandler.php:110)
Code block:
$this->logger->warning('[PayPlug] Cannot apply payment transition, payment is already in an incompatible state.', [Explanation paragraph: can() can be false both for truly incompatible states and for “already applied” / idempotent scenarios; the current message only mentions incompatibility, which can cause unnecessary alerting/confusion.
Fix: Adjust the message to cover both cases.
$this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [Heading: Operational implications
Subtitle (bold): Skipped transitions become non-observable in Notify handler (NotifyPaymentRequestHandler.php:99)
Code block:
if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
$this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition);
}Explanation paragraph: This avoids exceptions (good), but it also removes the previous signal (exception → failed PaymentRequest) when transitions can’t be applied for unexpected reasons. Without logging, real data/config issues may go unnoticed while requests still complete.
Fix: Log when can() is false (inject LoggerInterface and log payment id/state/transition/status when skipping).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Command/Handler/StatusPaymentRequestHandler.php | Adds can() guard + warning log around payment state transitions to avoid invalid-transition 500s. |
| src/Command/Handler/NotifyPaymentRequestHandler.php | Adds can() guard around payment state transitions to make notification handling idempotent. |
| PayPlugApiClientInterface::FAILED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL), | ||
| default => throw new \LogicException(sprintf('Unknown payment status "%s".', $payment->getDetails()['status'] ?? '')), // @phpstan-ignore-line - getDetails() return mixed | ||
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed |
There was a problem hiding this comment.
Maybe we could do a TransitionHelper with this to refacto those two files with this big duplica
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed | ||
| PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => PaymentTransitions::TRANSITION_CANCEL, | ||
| PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, | ||
| PayPlugApiClientInterface::STATUS_CAPTURED => PaymentTransitions::TRANSITION_COMPLETE, | ||
| PayPlugApiClientInterface::FAILED => PaymentTransitions::TRANSITION_FAIL, | ||
| default => null, | ||
| }; |
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed | ||
| PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => PaymentTransitions::TRANSITION_CANCEL, | ||
| PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, | ||
| PayPlugApiClientInterface::STATUS_CAPTURED => PaymentTransitions::TRANSITION_COMPLETE, | ||
| PayPlugApiClientInterface::FAILED => PaymentTransitions::TRANSITION_FAIL, | ||
| default => null, | ||
| }; |
| private function updatePaymentState(PaymentInterface $payment): bool | ||
| { | ||
| match ($payment->getDetails()['status'] ?? '') { | ||
| PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL), | ||
| PayPlugApiClientInterface::STATUS_AUTHORIZED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_AUTHORIZE), | ||
| PayPlugApiClientInterface::STATUS_CAPTURED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE), | ||
| PayPlugApiClientInterface::FAILED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL), | ||
| default => throw new \LogicException(sprintf('Unknown payment status "%s".', $payment->getDetails()['status'] ?? '')), // @phpstan-ignore-line - getDetails() return mixed | ||
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed |
| private function updatePaymentState(PaymentInterface $payment): void | ||
| { | ||
| match ($payment->getDetails()['status'] ?? '') { | ||
| PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL), | ||
| PayPlugApiClientInterface::STATUS_AUTHORIZED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_AUTHORIZE), | ||
| PayPlugApiClientInterface::STATUS_CAPTURED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE), | ||
| PayPlugApiClientInterface::FAILED => $this->stateMachine | ||
| ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL), | ||
| default => throw new \LogicException(sprintf('Unknown payment status "%s".', $payment->getDetails()['status'] ?? '')), // @phpstan-ignore-line - getDetails() return mixed | ||
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed |
| self::assertFalse($this->applier->apply($payment)); | ||
| } | ||
| } |
| $transition = match ($payment->getDetails()['status'] ?? '') { // @phpstan-ignore-line - getDetails() return mixed | ||
| PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => PaymentTransitions::TRANSITION_CANCEL, | ||
| PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, | ||
| PayPlugApiClientInterface::STATUS_CAPTURED => PaymentTransitions::TRANSITION_COMPLETE, | ||
| PayPlugApiClientInterface::FAILED => PaymentTransitions::TRANSITION_FAIL, | ||
| default => null, | ||
| }; | ||
|
|
||
| if (null === $transition) { | ||
| $this->logger->warning('[PayPlug] Cannot apply payment transition: unknown status.', [ | ||
| 'sylius_payment_id' => $payment->getId(), | ||
| 'payplug_payment_id' => $payment->getDetails()['payment_id'] ?? null, | ||
| 'status' => $payment->getDetails()['status'] ?? '', | ||
| ]); | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { | ||
| $this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [ | ||
| 'sylius_payment_id' => $payment->getId(), | ||
| 'payplug_payment_id' => $payment->getDetails()['payment_id'] ?? null, | ||
| 'current_state' => $payment->getState(), | ||
| 'transition' => $transition, | ||
| 'status' => $payment->getDetails()['status'] ?? '', |
…scalapay checkout
Description
PRE-3472: Fix error 500 when user proceed to return_cancel action on scalapay checkout
Motivation:
Related issue(s): Closes #
Type of Change
Checklist
Code Quality
Testing
Security & Ops