Skip to content

Accept pending mandate when starting a first-payment subscription - #322

Merged
sandervanhooft merged 2 commits into
mollie:mainfrom
McGo:fix/issue-289-mandate-pending-paypal
Jun 18, 2026
Merged

Accept pending mandate when starting a first-payment subscription#322
sandervanhooft merged 2 commits into
mollie:mainfrom
McGo:fix/issue-289-mandate-pending-paypal

Conversation

@McGo

@McGo McGo commented May 20, 2026

Copy link
Copy Markdown
Contributor

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 pending state — sometimes for up to 72 hours, until Mollie receives
the IBAN/account confirmation from the upstream payment scheme. During that
window MandatedSubscriptionBuilder::create() calls guardMollieMandate(),
which unconditionally throws MandateIsNotYetFinalizedException when the
mandate 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 pending or valid status —
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)

  1. Configure a plan with PayPal (or Belfius) as the first-payment method.
  2. Create a subscription via newSubscriptionViaMollieCheckout(...)->create().
  3. The customer completes the PayPal payment.
  4. Mollie delivers the first-payment webhook within seconds.
  5. MandatedSubscriptionBuilder::create() calls guardMollieMandate(), the
    mandate API still reports status: pending, and the handler throws
    MandateIsNotYetFinalizedException.
  6. Mollie retries the webhook with exponential back-off (capped at ~26h).
  7. The mandate transitions to valid 24–72h later — after Mollie has
    already stopped retrying.
  8. Result: the customer has paid, but the subscription is never started.

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 MandateUpdated event 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): optional
    parameter; when true, a pending mandate returns true instead of throwing.
    Default is false, so all existing callers behave exactly as before.
  • Billable::guardMollieMandate(bool $acceptPending = false): forwards the
    flag to validateMollieMandate.
  • MandatedSubscriptionBuilder::acceptPendingMandate(bool $accept = true):
    new fluent setter. create() passes the stored flag to
    guardMollieMandate(). The SubscriptionBuilder contract is unchanged.
  • FirstPayment\Actions\StartSubscription::execute(): calls
    $this->builder()->acceptPendingMandate() before create(), so the
    webhook-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() through
    StartSubscription::execute() to subscription creation with a pending
    mandate — the exact scenario reported on PayPal and Belfius.
  • StartSubscriptionTest::startsSubscriptionWhenMandateIsStillPending and
    startsSubscriptionWithTrialDaysWhenMandateIsStillPending: action-level
    coverage for the default and trial branches of subscription creation.
  • ManageChargesTest::mandatedChargeStillRejectsPendingMandate: an
    off-session charge against the same pending mandate still throws
    MandateIsNotYetFinalizedException.
  • BillableTest::validateMollieMandateAcceptsPendingWhenOptedIn and
    validateMollieMandateStillRejectsPendingByDefault: unit-level coverage
    of the new opt-in parameter on both branches.
  • The pre-existing BillableTest::throwExceptionIfMandateIsInPendingState
    remains green — it exercises newSubscriptionForMandateId, which does not
    opt in and must still throw.

Full suite: ./vendor/bin/phpunit — 249 tests, 1897 assertions, all green.

What this does not change

  • Aftercare webhook, refund, chargeback handling: unchanged.
  • Behavior when the mandate later transitions to invalid (e.g. Belfius IBAN
    comes back unusable) is unchanged: MandateClearedFromBillable still fires
    on the next charge attempt, and the merchant can react via the existing
    event.
  • This PR does not add polling or reconciliation for pending mandates that
    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.

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
McGo marked this pull request as ready for review May 20, 2026 09:51
@sandervanhooft

Copy link
Copy Markdown
Collaborator

Thanks @McGo !

@sandervanhooft
sandervanhooft merged commit 5117b2c into mollie:main Jun 18, 2026
11 checks passed
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.

Webhook Handling Error in Laravel Cashier Mollie

2 participants