Skip to content

Reconcile a rejected admin refund against the real EveryPay state - #6

Merged
acirtautas merged 1 commit into
mainfrom
refund-reconciliation
Aug 5, 2026
Merged

Reconcile a rejected admin refund against the real EveryPay state#6
acirtautas merged 1 commit into
mainfrom
refund-reconciliation

Conversation

@acirtautas

Copy link
Copy Markdown
Contributor

Problem

A refund made in the EveryPay merchant portal makes the admin Refund button fail with a generic "refund failed" flash until the portal callback is processed: EveryPay rejects the API refund (the amount exceeds what is standing), and that rejection was indistinguishable from a real failure - the payment stayed completed even though the money had already been reimbursed.

Change

RefundEveryPayPaymentHandler now treats a 4xx rejection as a hint only:

  • it re-reads GET /v4/payments/{payment_reference} - the same authenticated call the synchronizer uses, so no state change ever derives from an error response;
  • when EveryPay confirms refunded, the in-flight completed -> refunded transition completes with the fresh payment snapshot (the listener and its transaction are untouched - the commit path is exactly today's success path);
  • any other remote state, a failing re-read, a 5xx or a transport error rethrows the original rejection: rollback, error flash, payment stays completed in the database.

The remote-snapshot merge (payment_state/payment_method/standing_amount/synchronized_at) is now shared between the synchronizer and the refund handler via EveryPayGateway::withRemoteSnapshot(), so both write the same details shape.

Known trade-off (documented in the code): EveryPay reports refunded for partial portal refunds too, so a partial portal refund reconciles the Sylius payment to refunded - the same thing the callback path already does; standing_amount in the snapshot keeps the remainder visible on the admin order page.

Tests

  • 9 new unit cases: reconciliation across 400/404/422, rejection kept for non-refunded remote states, no re-read on 5xx/transport failures, original rejection preserved when the re-read itself fails.
  • 2 new Behat scenarios next to the portal-refund loop guard: a rejected refund that reconciles, and a rejected refund with the money still in place that stays rejected.

Gates

phpunit (both suites), phpstan level 9, ecs, behat --strict - all green, also under composer update --prefer-lowest --prefer-stable.

A refund made in the EveryPay merchant portal used to make the admin
Refund button fail with a generic error until the portal callback was
processed: EveryPay rejects the API refund because the money already
left the account, and the rejection was indistinguishable from a real
failure.

The refund handler now treats a 4xx rejection as a hint only: it
re-reads the authoritative payment state and, when EveryPay confirms
refunded, completes the in-flight refund transition with the fresh
snapshot. Any other rejection, server error or transport failure keeps
the existing behavior - transaction rollback, error flash, payment
stays completed in the database.

The remote snapshot merge is now shared between the synchronizer and
the refund handler via EveryPayGateway::withRemoteSnapshot().
@acirtautas
acirtautas merged commit e97da1b into main Aug 5, 2026
8 checks passed
@acirtautas
acirtautas deleted the refund-reconciliation branch August 5, 2026 13:17
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