Accept pending mandate when starting a first-payment subscription - #322
Merged
sandervanhooft merged 2 commits intoJun 18, 2026
Merged
Conversation
PayPal and Belfius first payments leave the customer mandate in `pending` state for up to 72h after collection — longer than Mollie's webhook retry window (~26h). The strict mandate guard in `MandatedSubscriptionBuilder` throws `MandateIsNotYetFinalizedException`, the webhook fails, retries run out, and the subscription is never started. Mollie's recurring-payments documentation states that a subscription should be created when the mandate is `pending` or `valid` — the first payment is guaranteed at that point. Adds an opt-in `acceptPendingMandate()` on `MandatedSubscriptionBuilder` and a matching `$acceptPending` parameter on `Billable::validateMollieMandate` and `guardMollieMandate`. `StartSubscription::execute()` opts in. Off-session charges, direct `MandatedSubscriptionBuilder::create()` calls, and `newSubscriptionForMandateId` remain strict. Closes mollie#289. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
McGo
marked this pull request as ready for review
May 20, 2026 09:51
Collaborator
|
Thanks @McGo ! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Accept pending mandate when starting a first-payment subscription
Closes #289.
Problem
After a successful first payment via PayPal or Belfius, the customer's mandate
stays in
pendingstate — sometimes for up to 72 hours, until Mollie receivesthe IBAN/account confirmation from the upstream payment scheme. During that
window
MandatedSubscriptionBuilder::create()callsguardMollieMandate(),which unconditionally throws
MandateIsNotYetFinalizedExceptionwhen themandate is pending. The webhook fails, Mollie retries with exponential
back-off, but its retry window (≈26h) is shorter than PayPal's and Belfius's
finalization window (up to 72h). The result: the customer has paid, the
mandate eventually becomes valid, but the subscription is never started.
This was also seen sporadically on cards in test mode and is the most
frequently reopened complaint on this issue (April 2025 → February 2026).
Mollie's own recurring-payments documentation states that a subscription
should be created when the mandate is in either
pendingorvalidstatus —the first payment is already collected and the mandate is guaranteed to
finalize. The strict guard was introduced in #94 specifically to leverage
Mollie's webhook retry as a workaround for Belfius (#70); with PayPal added
to the long-pending category, that workaround no longer covers the window.
Reproduction (before this PR)
newSubscriptionViaMollieCheckout(...)->create().MandatedSubscriptionBuilder::create()callsguardMollieMandate(), themandate API still reports
status: pending, and the handler throwsMandateIsNotYetFinalizedException.valid24–72h later — after Mollie hasalready stopped retrying.
With this PR step 5 succeeds on the first webhook delivery; the subscription
is created, the customer's mandate continues finalizing in the background,
and the
MandateUpdatedevent still fires as before.Solution
Treat a pending mandate as valid in the first-payment flow only. Recurring /
off-session charges (
MandatedChargeBuilder,Order::processFullPayment,direct
MandatedSubscriptionBuilder::create(),newSubscriptionForMandateId)keep the existing strict behavior — for merchant-initiated charges a pending
mandate offers no payment guarantee.
Changes
Billable::validateMollieMandate(bool $acceptPending = false): optionalparameter; when
true, a pending mandate returnstrueinstead of throwing.Default is
false, so all existing callers behave exactly as before.Billable::guardMollieMandate(bool $acceptPending = false): forwards theflag to
validateMollieMandate.MandatedSubscriptionBuilder::acceptPendingMandate(bool $accept = true):new fluent setter.
create()passes the stored flag toguardMollieMandate(). TheSubscriptionBuildercontract is unchanged.FirstPayment\Actions\StartSubscription::execute(): calls$this->builder()->acceptPendingMandate()beforecreate(), so thewebhook-triggered first-payment flow opts in to the relaxed validation.
No public API breakage, no schema changes, no new dependencies.
Tests
FirstPaymentHandlerTest::startsSubscriptionWhenMandateIsStillPending:end-to-end coverage from
FirstPaymentHandler::execute()throughStartSubscription::execute()to subscription creation with a pendingmandate — the exact scenario reported on PayPal and Belfius.
StartSubscriptionTest::startsSubscriptionWhenMandateIsStillPendingandstartsSubscriptionWithTrialDaysWhenMandateIsStillPending: action-levelcoverage for the default and trial branches of subscription creation.
ManageChargesTest::mandatedChargeStillRejectsPendingMandate: anoff-session charge against the same pending mandate still throws
MandateIsNotYetFinalizedException.BillableTest::validateMollieMandateAcceptsPendingWhenOptedInandvalidateMollieMandateStillRejectsPendingByDefault: unit-level coverageof the new opt-in parameter on both branches.
BillableTest::throwExceptionIfMandateIsInPendingStateremains green — it exercises
newSubscriptionForMandateId, which does notopt in and must still throw.
Full suite:
./vendor/bin/phpunit— 249 tests, 1897 assertions, all green.What this does not change
invalid(e.g. Belfius IBANcomes back unusable) is unchanged:
MandateClearedFromBillablestill fireson the next charge attempt, and the merchant can react via the existing
event.
never finalize within Mollie's retry window — that scenario is now
superseded by accepting the pending mandate up front, because Mollie has
guaranteed the first payment at that point.