Skip to content

Guard against missing status in CompletePaidPaymentsCommand#523

Open
Renrhaf wants to merge 1 commit into
Sylius:2.1from
Renrhaf:fix/guard-undefined-status-in-complete-payments
Open

Guard against missing status in CompletePaidPaymentsCommand#523
Renrhaf wants to merge 1 commit into
Sylius:2.1from
Renrhaf:fix/guard-undefined-status-in-complete-payments

Conversation

@Renrhaf
Copy link
Copy Markdown

@Renrhaf Renrhaf commented Apr 21, 2026

Q A
Branch? 2.1
Bug fix? yes
New feature? no
BC breaks? no
Deprecations? no
Related tickets n/a
License MIT

The bug

CompletePaidPaymentsCommand::execute() runs sylius-paypal:complete-payments (typically from cron) and, for each PayPal Payment stuck in processing, calls OrderDetailsApi::get() and checks $details['status'] === 'COMPLETED'.

PayPalClient::request() never throws on non-2xx responses — when an API call fails (for example when an uncaptured PayPal order has expired after ~3h and GET /v2/checkout/orders/{id} returns 404 RESOURCE_NOT_FOUND) it logs the error but silently returns the JSON error body as an array. That body looks like { "name": "RESOURCE_NOT_FOUND", "debug_id": "…", "message": "…" } — no status key.

Consequence: every hourly cron tick emits Warning: Undefined array key "status" (promoted to ErrorException by Symfony's error handler), the Payment keeps sitting in processing, and the same cycle repeats forever for each abandoned PayPal checkout. One real-world production instance collected ~9 duplicate Sentry events over 9 hours for a single abandoned order before this was caught.

Reproduction: create a PayPal order through the shop, close the PayPal approval window without clicking Pay, then wait > 3h (or otherwise expire the order on PayPal's side) and trigger the cron.

The fix

Skip the payment when the response has no status key — the underlying error has already been logged inside PayPalClient::request(). The happy path (status === 'COMPLETED' → transition) is unchanged.

Only one check is added; the diff is a handful of lines in the command plus a dedicated unit test covering:

  • the COMPLETED happy path (non-regression)
  • the new "error payload without status" branch, including an active assertion that no PHP warning is raised
  • a non-terminal status (APPROVED) is left alone
  • non-PayPal gateways are still skipped

Out of scope

This PR is intentionally minimal. It stops the warning and the hourly log noise, but a Payment whose PayPal order has permanently disappeared will still stay processing and be re-polled until it is cancelled by hand. Deciding when to automatically transition such payments to failed (retry-limit? age-based? explicit 404 detection?) is a bigger design call better left to a follow-up.

When PayPalClient::request() cannot reach the PayPal API (for example
when an uncaptured order has expired after ~3h and GET /v2/checkout/
orders/{id} returns 404 RESOURCE_NOT_FOUND), it logs the error and
silently returns the error payload instead of throwing. That payload
has no "status" key, so the existing `$details['status'] === 'COMPLETED'`
check in CompletePaidPaymentsCommand emits a PHP warning
("Undefined array key \"status\"") — promoted to an ErrorException by
Symfony's error handler — on every hourly cron tick, forever, for each
abandoned PayPal checkout.

Skip the payment when the response has no status key; the underlying
error has already been logged inside PayPalClient. The happy path is
unchanged.

Adds a dedicated unit test for CompletePaidPaymentsCommand covering
the COMPLETED path, the new no-status branch (with an assertion that
no PHP warning is raised), a non-terminal status (APPROVED), and the
non-PayPal gateway skip.
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.

1 participant