Reconciling an abandoned Worldline checkout records the payment failed. Letting the expiry sweep write the same non-event off without reconciling records it expired. Which of the two a site gets depends on the reconciliation budget and on whether the provider answered, not on anything the payer did.

What happens

A payer is sent to the hosted checkout and never completes it: they close the tab, or the session lapses. No card is ever entered, so Worldline creates no payment at all. The hosted checkout ends in status CANCELLED_BY_CONSUMER, which is the only status Worldline has for this. There is no separate expired status.

Two paths then disagree about what to record:

  • Reconciliation runs, from the return route or from the reaper with budget left. WorldlinePaymentFinalizer::resolveFromProvider() fetches the hosted checkout and calls finalize() with the created payment, which is NULL. finalize() asks WorldlineGateway::paymentAccepted(), false, then paymentCancelledByConsumer(), which needs a created payment and so is false too, and falls through to fail().
  • Reconciliation does not run, because reconcile_batch is 0, or the run spent its budget and the grace period has passed, or the provider could not be reached. PaymentManager::expireDue() writes expired.

Why failed is the wrong answer

No money was ever attempted. A real decline or error always produces a created provider payment in the REJECTED status, and that reaches fail() through the other branch. So the no-created-payment case never means failure, and recording it failed puts a routine abandonment in the bucket an operator is meant to investigate, and in the bucket a failure-rate report counts.

It also puts Worldline out of step with every other gateway. PaymentGatewayBase::reconcile() answers NothingHappened, so the same abandonment on the manual or the simulator gateway is recorded expired.

Two smaller things fall out of the same line

  • Because finalize() always resolves the payment, resolveFromProvider() can never return NothingHappened for a hosted checkout it successfully fetched. That branch, and the notice naming the hosted checkout status, are unreachable.
  • The hosted checkout status is fetched on every reconciliation and used for nothing except that unreachable log line.

Proposed resolution

Decide the no-created-payment case from the hosted checkout status and the payment deadline, and never fail it:

  • The checkout is still open, so nothing final has happened: report NothingHappened and leave the payment pending. The caller deadline decides, exactly as it does for every other gateway.
  • The checkout has ended with no payment created and the deadline has passed: record it expired. Worldline cannot tell a lapsed session from a cancelled one, but the deadline can.
  • The checkout has ended with no payment created before the deadline: the payer cancelled, so record it cancelled, which is what cancelPending() already exists for.

A created payment keeps exactly the behaviour it has today: accepted is recorded, CANCELLED is a cancellation, anything else is a failure. finalize() can then take a non-nullable created payment, since the webhook, its only other caller, already returns early when there is none.

Prose to sweep in the same commit: the webhook not-configured warning says payments whose payer does not return will settle only when the expiry sweep asks the provider, which today means recorded failed, and the state documentation describes neither the cancelled nor the expired outcome for an abandoned checkout.

Issue fork kessai-3614344

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mably created an issue. See original summary.

mably’s picture

Title: Record an abandoned Worldline checkout as expired or cancelled, never failed » Leave an abandoned Worldline checkout to its deadline, instead of recording it failed
Status: Active » Needs review

MR !33 is up, and it does not take the resolution proposed above. Recording cancelled before the deadline and expired after it turns out to be unsafe.

CANCELLED_BY_CONSUMER does not mean the payer pressed cancel. A hosted checkout session can lapse while the payment still has time left: the session is clamped to be no longer than the payment deadline, but nothing stops it being shorter, and Worldline enforces a minimum of one minute on it. Recording that as a cancellation would put a payment into a terminal state while the payer could still have completed it from a fresh session.

So the fix is smaller than proposed: a checkout that created no provider payment is not settled by the finalizer at all. It reports NothingHappened, and the payment deadline decides through the sweep, exactly as it does for a gateway that has no provider to ask. That still removes the two-answers-for-one-event problem this issue is about, and it does it without inventing a cancelled/lapsed distinction the provider cannot actually support. The one distinction that survives is the real one: a created payment in the CANCELLED status is still recorded cancelled, because there the payer did act.

The title is updated to match what the fix does.

  • mably committed 394fec0c on 1.x
    fix: #3614344 Leave an abandoned Worldline checkout to its deadline,...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

mably’s picture

Merged to 1.x as 394fec0.

Both directions are on the record. With the fix, 11 of 11 tests in WorldlineFinalizerTest pass. The test-only lane, which applies the tests without the fix, fails 3 of them, and on the values this issue is about: the sweep records failed where it should record expired, and the finalizer answers Resolved where it should answer NothingHappened. The other 8 pass unchanged there, so the new tests fail because of the bug rather than because they do not fit the old code.

Every job in the pipeline was read individually rather than trusting the badge, including the ones marked allow_failure: cspell, eslint, phpcs and phpstan all passed.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.