From e529d5f3108c0e69851ea815c5abe118d1cd5739 Mon Sep 17 00:00:00 2001 From: Mirko Haaser Date: Wed, 20 May 2026 11:43:18 +0200 Subject: [PATCH 1/2] Accept pending mandate when starting a first-payment subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #289. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Billable.php | 18 ++++- .../Actions/StartSubscription.php | 6 ++ .../MandatedSubscriptionBuilder.php | 21 +++++- tests/BillableTest.php | 25 +++++++ tests/Charge/ManageChargesTest.php | 28 ++++++++ .../Actions/StartSubscriptionTest.php | 66 +++++++++++++++++ .../FirstPayment/FirstPaymentHandlerTest.php | 70 +++++++++++++++++++ 7 files changed, 230 insertions(+), 4 deletions(-) diff --git a/src/Billable.php b/src/Billable.php index 24e7a14b..fc98db90 100644 --- a/src/Billable.php +++ b/src/Billable.php @@ -502,11 +502,22 @@ public function pendingMollieMandate() /** * Checks whether the Mollie mandate is still valid. If not, clears it. * + * When $acceptPending is true, a pending mandate is treated as valid. This is + * used by the first-payment flow where Mollie has already collected the + * payment and the mandate is guaranteed to finalize (see issue #289). For + * recurring/off-session charges the default strict behavior is preserved. + * * @return bool + * + * @throws \Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException */ - public function validateMollieMandate() + public function validateMollieMandate(bool $acceptPending = false) { if ($this->pendingMollieMandate()) { + if ($acceptPending) { + return true; + } + throw new MandateIsNotYetFinalizedException(); } @@ -523,10 +534,11 @@ public function validateMollieMandate() * @return bool * * @throws \Laravel\Cashier\Exceptions\InvalidMandateException + * @throws \Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException */ - public function guardMollieMandate() + public function guardMollieMandate(bool $acceptPending = false) { - throw_unless($this->validateMollieMandate(), new InvalidMandateException()); + throw_unless($this->validateMollieMandate($acceptPending), new InvalidMandateException()); return true; } diff --git a/src/FirstPayment/Actions/StartSubscription.php b/src/FirstPayment/Actions/StartSubscription.php index 2fd769e6..6d8cb228 100644 --- a/src/FirstPayment/Actions/StartSubscription.php +++ b/src/FirstPayment/Actions/StartSubscription.php @@ -184,6 +184,12 @@ public function execute() $this->builder()->nextPaymentAt($this->plan->interval()->getEndOfNextSubscriptionCycle()); } + // The first payment has already been collected by Mollie, so the mandate + // is guaranteed to finalize even when it is still pending at this point + // (e.g. PayPal or Belfius, where finalization can take up to 72h and + // exceeds Mollie's webhook retry window). See issue #289. + $this->builder()->acceptPendingMandate(); + // Create the subscription, scheduling the next payment $subscription = $this->builder()->create(); diff --git a/src/SubscriptionBuilder/MandatedSubscriptionBuilder.php b/src/SubscriptionBuilder/MandatedSubscriptionBuilder.php index 2a88e98c..a4bbfee9 100644 --- a/src/SubscriptionBuilder/MandatedSubscriptionBuilder.php +++ b/src/SubscriptionBuilder/MandatedSubscriptionBuilder.php @@ -66,6 +66,9 @@ class MandatedSubscriptionBuilder implements Contract /** @var bool */ protected $validateCoupon = true; + /** @var bool */ + protected $pendingMandateAccepted = false; + /** * Create a new subscription builder instance. * @@ -93,7 +96,7 @@ public function __construct(Model $owner, string $name, string $plan) */ public function create() { - $this->owner->guardMollieMandate(); + $this->owner->guardMollieMandate($this->pendingMandateAccepted); $now = now(); return DB::transaction(function () use ($now) { @@ -242,4 +245,20 @@ public function skipCouponHandling() return $this; } + + /** + * Treat a pending Mollie mandate as valid when creating the subscription. + * + * Used by the first-payment flow: Mollie has already collected the payment + * and guarantees the mandate will finalize, even when finalization takes + * longer than the webhook retry window (PayPal, Belfius). See issue #289. + * + * @return $this + */ + public function acceptPendingMandate(bool $accept = true) + { + $this->pendingMandateAccepted = $accept; + + return $this; + } } diff --git a/tests/BillableTest.php b/tests/BillableTest.php index 0568d0e7..c592ae1e 100644 --- a/tests/BillableTest.php +++ b/tests/BillableTest.php @@ -71,6 +71,31 @@ public function throwExceptionIfMandateIsInPendingState() $user->newSubscriptionForMandateId('mdt_unique_mandate_id', 'main', 'monthly-10-1')->create(); } + #[Test] + public function validateMollieMandateAcceptsPendingWhenOptedIn() + { + // Regression test for issue #289: callers in the first-payment flow can + // opt in to treating a pending mandate as valid, because Mollie has + // already collected the payment and guarantees finalization. + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + $user = $this->getMandatedUser(false); + + $this->assertTrue($user->validateMollieMandate(acceptPending: true)); + } + + #[Test] + public function validateMollieMandateStillRejectsPendingByDefault() + { + $this->expectException(MandateIsNotYetFinalizedException::class); + + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + $user = $this->getMandatedUser(false); + + $user->validateMollieMandate(); + } + #[Test] public function returnsDefaultSubscriptionBuilderIfOwnerHasValidMandateId() { diff --git a/tests/Charge/ManageChargesTest.php b/tests/Charge/ManageChargesTest.php index ca44c2ed..421d6eb1 100644 --- a/tests/Charge/ManageChargesTest.php +++ b/tests/Charge/ManageChargesTest.php @@ -2,10 +2,14 @@ namespace Laravel\Cashier\Tests\Charge; +use Laravel\Cashier\Charge\ChargeItem; use Laravel\Cashier\Charge\FirstPaymentChargeBuilder; use Laravel\Cashier\Charge\MandatedChargeBuilder; +use Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException; use Laravel\Cashier\Tests\BaseTestCase; use Laravel\Cashier\Tests\Fixtures\User; +use Money\Currency; +use Money\Money; use PHPUnit\Framework\Attributes\Test; class ManageChargesTest extends BaseTestCase @@ -30,4 +34,28 @@ public function useNewMandatedCharge() $this->assertInstanceOf(MandatedChargeBuilder::class, $owner->newCharge()); } + + #[Test] + public function mandatedChargeStillRejectsPendingMandate() + { + // Regression test for issue #289: while the first-payment flow accepts + // a pending mandate (Mollie has already collected that payment), + // off-session/recurring charges must remain strict — a pending mandate + // offers no payment guarantee for charges initiated by the merchant. + $this->expectException(MandateIsNotYetFinalizedException::class); + + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + $owner = $this->getMandatedUser(true, [ + 'mollie_mandate_id' => 'mdt_unique_mandate_id', + 'mollie_customer_id' => 'cst_unique_customer_id', + ]); + + $builder = new MandatedChargeBuilder($owner); + $builder->addItem(new ChargeItem( + $owner, + new Money(1000, new Currency('EUR')), + 'Test charge' + ))->create(); + } } diff --git a/tests/FirstPayment/Actions/StartSubscriptionTest.php b/tests/FirstPayment/Actions/StartSubscriptionTest.php index aef4432a..f7f44545 100644 --- a/tests/FirstPayment/Actions/StartSubscriptionTest.php +++ b/tests/FirstPayment/Actions/StartSubscriptionTest.php @@ -654,6 +654,72 @@ public function canStartSubscriptionWithCouponAndTrial() $this->assertEquals(20, $scheduledItem->tax_percentage); } + #[Test] + public function startsSubscriptionWhenMandateIsStillPending() + { + // Regression test for issue #289: PayPal and Belfius mandates can remain + // pending for up to 72h after the first payment, which exceeds Mollie's + // webhook retry window. The first payment is already collected and the + // mandate is guaranteed to finalize, so the subscription must be started. + Carbon::setTestNow('2019-01-29'); + + $user = $this->getMandatedUser(true); + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + + $this->assertFalse($user->subscribed('default')); + + $action = new StartSubscription( + $user, + 'default', + 'monthly-10-1' + ); + + $items = $action->execute(); + $user = $user->fresh(); + + $this->assertTrue($user->subscribed('default')); + $this->assertInstanceOf(OrderItemCollection::class, $items); + $this->assertCount(1, $items); + + $subscription = $user->subscription('default'); + $this->assertEquals(2, $subscription->orderItems()->count()); + } + + #[Test] + public function startsSubscriptionWithTrialDaysWhenMandateIsStillPending() + { + // Regression test for issue #289: covers the trial branch of + // MandatedSubscriptionBuilder::create() to ensure the pending-mandate + // opt-in also applies when the subscription starts on a trial. + $user = $this->getMandatedUser(true, ['tax_percentage' => 20]); + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + + $this->assertFalse($user->subscribed('default')); + + $action = new StartSubscription( + $user, + 'default', + 'monthly-10-1' + ); + + $action->trialDays(5); + + $items = $action->execute(); + $user = $user->fresh(); + + $this->assertTrue($user->subscribed('default')); + $this->assertTrue($user->onTrial()); + $this->assertInstanceOf(OrderItemCollection::class, $items); + $this->assertCount(1, $items); + + $subscription = $user->subscription('default'); + $this->assertEquals(2, $subscription->orderItems()->count()); + $this->assertCarbon(now()->addDays(5), $subscription->cycle_ends_at); + $this->assertCarbon(now()->addDays(5), $subscription->trial_ends_at); + } + /** * Check if the action can be built using the payload, and then can return the same payload. * diff --git a/tests/FirstPayment/FirstPaymentHandlerTest.php b/tests/FirstPayment/FirstPaymentHandlerTest.php index 97790e5e..83a63803 100644 --- a/tests/FirstPayment/FirstPaymentHandlerTest.php +++ b/tests/FirstPayment/FirstPaymentHandlerTest.php @@ -6,6 +6,7 @@ use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\MandateUpdated; use Laravel\Cashier\FirstPayment\Actions\AddBalance; +use Laravel\Cashier\FirstPayment\Actions\StartSubscription; use Laravel\Cashier\FirstPayment\FirstPaymentHandler; use Laravel\Cashier\Tests\BaseTestCase; use Laravel\Cashier\Tests\Fixtures\User; @@ -85,6 +86,75 @@ public function handlesMolliePayments() }); } + #[Test] + public function startsSubscriptionWhenMandateIsStillPending() + { + // End-to-end regression for issue #289: this exercises the path + // FirstPaymentWebhookController → FirstPaymentHandler::execute() + // → StartSubscription::execute() → MandatedSubscriptionBuilder::create() + // with a pending mandate, which is the exact scenario reported for + // PayPal and Belfius first payments. + Event::fake(); + $this->withConfiguredPlans(); + $this->withMockedGetMollieCustomer(); + $this->withMockedGetMollieMandatePending(); + + $molliePayment = $this->getStartSubscriptionPaymentStub(); + + $owner = User::factory()->create([ + 'id' => $molliePayment->metadata->owner->id, + 'mollie_customer_id' => 'cst_unique_customer_id', + ]); + Cashier::$paymentModel::createFromMolliePayment($molliePayment, $owner); + + $this->assertFalse($owner->subscribed('default')); + + $handler = new FirstPaymentHandler($molliePayment); + $order = $handler->execute(); + + $owner = $owner->fresh(); + + $this->assertTrue($owner->subscribed('default')); + $this->assertEquals('mdt_unique_mandate_id', $owner->mollie_mandate_id); + $this->assertInstanceOf(Cashier::$orderModel, $order); + $this->assertTrue($order->isProcessed()); + + Event::assertDispatched(MandateUpdated::class); + } + + protected function getStartSubscriptionPaymentStub(): MolliePayment + { + $payment = new MolliePayment(new MollieApiClient); + $payment->sequenceType = 'first'; + $payment->id = 'tr_unique_start_subscription_payment_id'; + $payment->customerId = 'cst_unique_customer_id'; + $payment->mandateId = 'mdt_unique_mandate_id'; + $payment->amount = (object) ['value' => '10.00', 'currency' => 'EUR']; + $payment->status = PaymentStatus::PAID; + $payment->metadata = json_decode(json_encode([ + 'owner' => [ + 'type' => User::class, + 'id' => 1, + ], + 'actions' => [ + [ + 'handler' => StartSubscription::class, + 'description' => 'Monthly payment', + 'subtotal' => [ + 'currency' => 'EUR', + 'value' => '10.00', + ], + 'taxPercentage' => 0, + 'plan' => 'monthly-10-1', + 'name' => 'default', + 'quantity' => 1, + ], + ], + ])); + + return $payment; + } + protected function getMandatePaymentStub(): MolliePayment { $payment = new MolliePayment(new MollieApiClient()); From 4990888636763bd0e1d030737338085051fbd79e Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Thu, 18 Jun 2026 16:52:34 +0200 Subject: [PATCH 2/2] Trigger fresh CI for PR 322